Merge pull request #15271 from overleaf/jpa-lazy-loading

[web] lazy load big optional UI elements

GitOrigin-RevId: 18d723c66834be3984b74c3c89cfb46e2fffbfc1
This commit is contained in:
Jakob Ackermann 2023-10-17 10:41:40 +02:00 committed by Copybot
parent 0e52c245ce
commit 83cf21d8cf
18 changed files with 186 additions and 101 deletions

View file

@ -1,8 +1,7 @@
import React, { useEffect } from 'react' import React, { lazy, Suspense, useEffect, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MessageList from './message-list'
import MessageInput from './message-input' import MessageInput from './message-input'
import InfiniteScroll from './infinite-scroll' import InfiniteScroll from './infinite-scroll'
import ChatFallbackError from './chat-fallback-error' import ChatFallbackError from './chat-fallback-error'
@ -14,6 +13,8 @@ import { FetchError } from '../../../infrastructure/fetch-json'
import { useChatContext } from '../context/chat-context' import { useChatContext } from '../context/chat-context'
import LoadingSpinner from '../../../shared/components/loading-spinner' import LoadingSpinner from '../../../shared/components/loading-spinner'
const MessageList = lazy(() => import('./message-list'))
const ChatPane = React.memo(function ChatPane() { const ChatPane = React.memo(function ChatPane() {
const { t } = useTranslation() const { t } = useTranslation()
@ -48,6 +49,14 @@ const ChatPane = React.memo(function ChatPane() {
0 0
) )
// Keep the chat pane in the DOM to avoid resetting the form input and re-rendering MathJax content.
const [chatOpenedOnce, setChatOpenedOnce] = useState(chatIsOpen)
useEffect(() => {
if (chatIsOpen) {
setChatOpenedOnce(true)
}
}, [chatIsOpen])
if (error) { if (error) {
// let user try recover from fetch errors // let user try recover from fetch errors
if (error instanceof FetchError) { if (error instanceof FetchError) {
@ -59,6 +68,9 @@ const ChatPane = React.memo(function ChatPane() {
if (!user) { if (!user) {
return null return null
} }
if (!chatOpenedOnce) {
return null
}
return ( return (
<aside className="chat"> <aside className="chat">
@ -71,13 +83,15 @@ const ChatPane = React.memo(function ChatPane() {
> >
<div> <div>
<h2 className="sr-only">{t('chat')}</h2> <h2 className="sr-only">{t('chat')}</h2>
{status === 'pending' && <LoadingSpinner delay={500} />} <Suspense fallback={<LoadingSpinner delay={500} />}>
{shouldDisplayPlaceholder && <Placeholder />} {status === 'pending' && <LoadingSpinner delay={500} />}
<MessageList {shouldDisplayPlaceholder && <Placeholder />}
messages={messages} <MessageList
userId={user.id} messages={messages}
resetUnreadMessages={markMessagesAsRead} userId={user.id}
/> resetUnreadMessages={markMessagesAsRead}
/>
</Suspense>
</div> </div>
</InfiniteScroll> </InfiniteScroll>
<MessageInput <MessageInput

View file

@ -0,0 +1,17 @@
import DownloadMenu from './download-menu'
import ActionsMenu from './actions-menu'
import HelpMenu from './help-menu'
import SyncMenu from './sync-menu'
import SettingsMenu from './settings-menu'
export default function EditorLeftMenuBody() {
return (
<>
<DownloadMenu />
<ActionsMenu />
<SyncMenu />
<SettingsMenu />
<HelpMenu />
</>
)
}

View file

@ -1,13 +1,11 @@
import DownloadMenu from './download-menu'
import ActionsMenu from './actions-menu'
import HelpMenu from './help-menu'
import { useLayoutContext } from '../../../shared/context/layout-context' import { useLayoutContext } from '../../../shared/context/layout-context'
import SyncMenu from './sync-menu'
import SettingsMenu from './settings-menu'
import LeftMenuMask from './left-menu-mask' import LeftMenuMask from './left-menu-mask'
import AccessibleModal from '../../../shared/components/accessible-modal' import AccessibleModal from '../../../shared/components/accessible-modal'
import { Modal } from 'react-bootstrap' import { Modal } from 'react-bootstrap'
import classNames from 'classnames' import classNames from 'classnames'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const EditorLeftMenuBody = lazy(() => import('./editor-left-menu-body'))
export default function EditorLeftMenu() { export default function EditorLeftMenu() {
const { leftMenuShown, setLeftMenuShown } = useLayoutContext() const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
@ -29,11 +27,9 @@ export default function EditorLeftMenu() {
className={classNames('full-size', { shown: leftMenuShown })} className={classNames('full-size', { shown: leftMenuShown })}
id="left-menu" id="left-menu"
> >
<DownloadMenu /> <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<ActionsMenu /> <EditorLeftMenuBody />
<SyncMenu /> </Suspense>
<SettingsMenu />
<HelpMenu />
</Modal.Body> </Modal.Body>
</AccessibleModal> </AccessibleModal>
{leftMenuShown && <LeftMenuMask />} {leftMenuShown && <LeftMenuMask />}

View file

@ -2,16 +2,19 @@ import { useTranslation } from 'react-i18next'
import FileTreeCreateNewDoc from './modes/file-tree-create-new-doc' import FileTreeCreateNewDoc from './modes/file-tree-create-new-doc'
import FileTreeImportFromUrl from './modes/file-tree-import-from-url' import FileTreeImportFromUrl from './modes/file-tree-import-from-url'
import FileTreeImportFromProject from './modes/file-tree-import-from-project' import FileTreeImportFromProject from './modes/file-tree-import-from-project'
import FileTreeUploadDoc from './modes/file-tree-upload-doc'
import FileTreeModalCreateFileMode from './file-tree-modal-create-file-mode' import FileTreeModalCreateFileMode from './file-tree-modal-create-file-mode'
import FileTreeCreateNameProvider from '../../contexts/file-tree-create-name' import FileTreeCreateNameProvider from '../../contexts/file-tree-create-name'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable' import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context' import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const createFileModeModules = importOverleafModules('createFileModes') const createFileModeModules = importOverleafModules('createFileModes')
const FileTreeUploadDoc = lazy(() => import('./modes/file-tree-upload-doc'))
export default function FileTreeModalCreateFileBody() { export default function FileTreeModalCreateFileBody() {
const { t } = useTranslation() const { t } = useTranslation()
@ -86,7 +89,11 @@ export default function FileTreeModalCreateFileBody() {
</FileTreeCreateNameProvider> </FileTreeCreateNameProvider>
)} )}
{newFileCreateMode === 'upload' && <FileTreeUploadDoc />} {newFileCreateMode === 'upload' && (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<FileTreeUploadDoc />
</Suspense>
)}
{createFileModeModules.map( {createFileModeModules.map(
({ import: { CreateFilePane }, path }) => ( ({ import: { CreateFilePane }, path }) => (

View file

@ -0,0 +1,33 @@
import { useHistoryContext } from '@/features/history/context/history-context'
import LoadingSpinner from '@/shared/components/loading-spinner'
import DiffView from '@/features/history/components/diff-view/diff-view'
import ChangeList from '@/features/history/components/change-list/change-list'
import { createPortal } from 'react-dom'
import HistoryFileTree from '@/features/history/components/history-file-tree'
const fileTreeContainer = document.getElementById('history-file-tree')
export default function HistoryContent() {
const { updatesInfo } = useHistoryContext()
let content
if (updatesInfo.loadingState === 'loadingInitial') {
content = <LoadingSpinner />
} else {
content = (
<>
<DiffView />
<ChangeList />
</>
)
}
return (
<>
{fileTreeContainer
? createPortal(<HistoryFileTree />, fileTreeContainer)
: null}
<div className="history-react">{content}</div>
</>
)
}

View file

@ -1,42 +1,23 @@
import ChangeList from './change-list/change-list' import { HistoryProvider } from '../context/history-context'
import DiffView from './diff-view/diff-view'
import { HistoryProvider, useHistoryContext } from '../context/history-context'
import { useLayoutContext } from '../../../shared/context/layout-context' import { useLayoutContext } from '../../../shared/context/layout-context'
import { createPortal } from 'react-dom' import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import HistoryFileTree from './history-file-tree'
import LoadingSpinner from '../../../shared/components/loading-spinner'
import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback' import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback'
import withErrorBoundary from '../../../infrastructure/error-boundary' import withErrorBoundary from '../../../infrastructure/error-boundary'
import { lazy, Suspense } from 'react'
const fileTreeContainer = document.getElementById('history-file-tree') const HistoryContent = lazy(() => import('./history-content'))
function Main() { function Main() {
const { view } = useLayoutContext() const { view } = useLayoutContext()
const { updatesInfo } = useHistoryContext()
if (view !== 'history') { if (view !== 'history') {
return null return null
} }
let content
if (updatesInfo.loadingState === 'loadingInitial') {
content = <LoadingSpinner />
} else {
content = (
<>
<DiffView />
<ChangeList />
</>
)
}
return ( return (
<> <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
{fileTreeContainer <HistoryContent />
? createPortal(<HistoryFileTree />, fileTreeContainer) </Suspense>
: null}
<div className="history-react">{content}</div>
</>
) )
} }

View file

@ -2,7 +2,7 @@ import { memo, Suspense } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import PdfLogsViewer from './pdf-logs-viewer' import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer' import PdfViewer from './pdf-viewer'
import LoadingSpinner from '../../../shared/components/loading-spinner' import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar' import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import FasterCompilesFeedback from './faster-compiles-feedback' import FasterCompilesFeedback from './faster-compiles-feedback'
@ -27,7 +27,7 @@ function PdfPreviewPane() {
<CompileTimeWarning /> <CompileTimeWarning />
)} )}
</PdfPreviewMessages> </PdfPreviewMessages>
<Suspense fallback={<LoadingPreview />}> <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<div className="pdf-viewer"> <div className="pdf-viewer">
<PdfViewer /> <PdfViewer />
<FasterCompilesFeedback /> <FasterCompilesFeedback />
@ -39,12 +39,4 @@ function PdfPreviewPane() {
) )
} }
function LoadingPreview() {
return (
<div className="pdf-loading-spinner-container">
<LoadingSpinner delay={500} />
</div>
)
}
export default memo(PdfPreviewPane) export default memo(PdfPreviewPane)

View file

@ -1,9 +1,11 @@
import BlankProjectModal from './blank-project-modal' import BlankProjectModal from './blank-project-modal'
import ExampleProjectModal from './example-project-modal' import ExampleProjectModal from './example-project-modal'
import UploadProjectModal from './upload-project-modal'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { JSXElementConstructor } from 'react' import { JSXElementConstructor, lazy, Suspense } from 'react'
import { Nullable } from '../../../../../../types/utils' import { Nullable } from '../../../../../../types/utils'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const UploadProjectModal = lazy(() => import('./upload-project-modal'))
export type NewProjectButtonModalVariant = export type NewProjectButtonModalVariant =
| 'blank_project' | 'blank_project'
@ -30,7 +32,11 @@ function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
case 'example_project': case 'example_project':
return <ExampleProjectModal onHide={onHide} /> return <ExampleProjectModal onHide={onHide} />
case 'upload_project': case 'upload_project':
return <UploadProjectModal onHide={onHide} /> return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<UploadProjectModal onHide={onHide} />
</Suspense>
)
case 'import_from_github': case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} /> return <ImportProjectFromGithubModalWrapper onHide={onHide} />
default: default:

View file

@ -1,11 +1,20 @@
import { Button, Modal, Grid } from 'react-bootstrap' import { Button, Modal, Grid } from 'react-bootstrap'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
import ShareModalBody from './share-modal-body'
import Icon from '../../../shared/components/icon' import Icon from '../../../shared/components/icon'
import AccessibleModal from '../../../shared/components/accessible-modal' import AccessibleModal from '../../../shared/components/accessible-modal'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ReadOnlyTokenLink } from './link-sharing'
import { useEditorContext } from '../../../shared/context/editor-context' import { useEditorContext } from '../../../shared/context/editor-context'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const ReadOnlyTokenLink = lazy(() =>
import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({
// re-export as default -- lazy can only handle default exports.
default: ReadOnlyTokenLink,
}))
)
const ShareModalBody = lazy(() => import('./share-modal-body'))
export default function ShareProjectModalContent({ export default function ShareProjectModalContent({
show, show,
@ -28,7 +37,13 @@ export default function ShareProjectModalContent({
<Modal.Body className="modal-body-share"> <Modal.Body className="modal-body-share">
<Grid fluid> <Grid fluid>
{isRestrictedTokenMember ? <ReadOnlyTokenLink /> : <ShareModalBody />} <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
{isRestrictedTokenMember ? (
<ReadOnlyTokenLink />
) : (
<ShareModalBody />
)}
</Suspense>
</Grid> </Grid>
</Modal.Body> </Modal.Body>

View file

@ -21,7 +21,7 @@ const sourceModes = new Map([
[FigureModalSource.EDIT_FIGURE, FigureModalEditFigureSource], [FigureModalSource.EDIT_FIGURE, FigureModalEditFigureSource],
]) ])
export const FigureModalBody = () => { export default function FigureModalBody() {
const { source, helpShown, sourcePickerShown, error, dispatch } = const { source, helpShown, sourcePickerShown, error, dispatch } =
useFigureModalContext() useFigureModalContext()
const Body = sourceModes.get(source) const Body = sourceModes.get(source)

View file

@ -6,9 +6,8 @@ import {
useFigureModalContext, useFigureModalContext,
useFigureModalExistingFigureContext, useFigureModalExistingFigureContext,
} from './figure-modal-context' } from './figure-modal-context'
import { FigureModalBody } from './figure-modal-body'
import { FigureModalFooter } from './figure-modal-footer' import { FigureModalFooter } from './figure-modal-footer'
import { memo, useCallback, useEffect } from 'react' import { lazy, memo, Suspense, useCallback, useEffect } from 'react'
import { useCodeMirrorViewContext } from '../codemirror-editor' import { useCodeMirrorViewContext } from '../codemirror-editor'
import { ChangeSpec } from '@codemirror/state' import { ChangeSpec } from '@codemirror/state'
import { import {
@ -22,6 +21,9 @@ import { useTranslation } from 'react-i18next'
import useEventListener from '../../../../shared/hooks/use-event-listener' import useEventListener from '../../../../shared/hooks/use-event-listener'
import { prepareLines } from '../../utils/prepare-lines' import { prepareLines } from '../../utils/prepare-lines'
import { FeedbackBadge } from '@/shared/components/feedback-badge' import { FeedbackBadge } from '@/shared/components/feedback-badge'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
const FigureModalBody = lazy(() => import('./figure-modal-body'))
export const FigureModal = memo(function FigureModal() { export const FigureModal = memo(function FigureModal() {
return ( return (
@ -277,7 +279,9 @@ const FigureModalContent = () => {
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<FigureModalBody /> <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
<FigureModalBody />
</Suspense>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>

View file

@ -1,5 +1,5 @@
import { lazy, memo, Suspense } from 'react' import { lazy, memo, Suspense } from 'react'
import LoadingSpinner from '../../../shared/components/loading-spinner' import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import withErrorBoundary from '../../../infrastructure/error-boundary' import withErrorBoundary from '../../../infrastructure/error-boundary'
import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback' import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback'
@ -10,13 +10,7 @@ const CodeMirrorEditor = lazy(
function SourceEditor() { function SourceEditor() {
return ( return (
<Suspense <Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
fallback={
<div className="pdf-loading-spinner-container">
<LoadingSpinner delay={500} />
</div>
}
>
<CodeMirrorEditor /> <CodeMirrorEditor />
</Suspense> </Suspense>
) )

View file

@ -36,3 +36,22 @@ LoadingSpinner.propTypes = {
} }
export default LoadingSpinner export default LoadingSpinner
export function FullSizeLoadingSpinner({
delay = 0,
minHeight,
}: {
delay?: number
minHeight?: string
}) {
return (
<div className="full-size-loading-spinner-container" style={{ minHeight }}>
<LoadingSpinner delay={delay} />
</div>
)
}
FullSizeLoadingSpinner.propTypes = {
delay: PropTypes.number,
minHeight: PropTypes.string,
}

View file

@ -41,6 +41,7 @@
@import 'components/notifications.less'; @import 'components/notifications.less';
@import 'components/pager.less'; @import 'components/pager.less';
@import 'components/labels.less'; @import 'components/labels.less';
@import 'components/loading-spinner';
//@import "components/jumbotron.less"; //@import "components/jumbotron.less";
@import 'components/thumbnails.less'; @import 'components/thumbnails.less';
@import 'components/alerts.less'; @import 'components/alerts.less';

View file

@ -582,14 +582,6 @@
); );
} }
.pdf-loading-spinner-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.btn-secondary-compile-timeout-override { .btn-secondary-compile-timeout-override {
color: #1b222c; color: #1b222c;
background-color: #ffffff; background-color: #ffffff;

View file

@ -0,0 +1,7 @@
.full-size-loading-spinner-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -55,6 +55,7 @@
@import 'components/footer.less'; @import 'components/footer.less';
@import 'components/notifications.less'; @import 'components/notifications.less';
@import 'components/labels.less'; @import 'components/labels.less';
@import 'components/loading-spinner';
@import 'components/thumbnails.less'; @import 'components/thumbnails.less';
@import 'components/alerts.less'; @import 'components/alerts.less';
@import 'components/progress-bars.less'; @import 'components/progress-bars.less';

View file

@ -382,14 +382,16 @@ describe('<FileTreeModalCreateFile/>', function () {
// the submit button should not be present // the submit button should not be present
expect(screen.queryByRole('button', { name: 'Create' })).to.be.null expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
const dropzone = screen.getByLabelText('File Uploader') await waitFor(() => {
const dropzone = screen.getByLabelText('File Uploader')
expect(dropzone).not.to.be.null expect(dropzone).not.to.be.null
fireEvent.drop(dropzone, { fireEvent.drop(dropzone, {
dataTransfer: { dataTransfer: {
files: [new File(['test'], 'test.tex', { type: 'text/plain' })], files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
}, },
})
}) })
await waitFor(() => expect(requests).to.have.length(1)) await waitFor(() => expect(requests).to.have.length(1))
@ -415,14 +417,16 @@ describe('<FileTreeModalCreateFile/>', function () {
// the submit button should not be present // the submit button should not be present
expect(screen.queryByRole('button', { name: 'Create' })).to.be.null expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
const dropzone = screen.getByLabelText('File Uploader') await waitFor(() => {
const dropzone = screen.getByLabelText('File Uploader')
expect(dropzone).not.to.be.null expect(dropzone).not.to.be.null
fireEvent.paste(dropzone, { fireEvent.paste(dropzone, {
clipboardData: { clipboardData: {
files: [new File(['test'], 'test.tex', { type: 'text/plain' })], files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
}, },
})
}) })
await waitFor(() => expect(requests).to.have.length(1)) await waitFor(() => expect(requests).to.have.length(1))
@ -448,14 +452,16 @@ describe('<FileTreeModalCreateFile/>', function () {
// the submit button should not be present // the submit button should not be present
expect(screen.queryByRole('button', { name: 'Create' })).to.be.null expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
const dropzone = screen.getByLabelText('File Uploader') await waitFor(() => {
const dropzone = screen.getByLabelText('File Uploader')
expect(dropzone).not.to.be.null expect(dropzone).not.to.be.null
fireEvent.paste(dropzone, { fireEvent.paste(dropzone, {
clipboardData: { clipboardData: {
files: [new File(['test'], 'tes!t.tex', { type: 'text/plain' })], files: [new File(['test'], 'tes!t.tex', { type: 'text/plain' })],
}, },
})
}) })
await waitFor(() => expect(requests).to.have.length(1)) await waitFor(() => expect(requests).to.have.length(1))