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 { useTranslation } from 'react-i18next'
import MessageList from './message-list'
import MessageInput from './message-input'
import InfiniteScroll from './infinite-scroll'
import ChatFallbackError from './chat-fallback-error'
@ -14,6 +13,8 @@ import { FetchError } from '../../../infrastructure/fetch-json'
import { useChatContext } from '../context/chat-context'
import LoadingSpinner from '../../../shared/components/loading-spinner'
const MessageList = lazy(() => import('./message-list'))
const ChatPane = React.memo(function ChatPane() {
const { t } = useTranslation()
@ -48,6 +49,14 @@ const ChatPane = React.memo(function ChatPane() {
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) {
// let user try recover from fetch errors
if (error instanceof FetchError) {
@ -59,6 +68,9 @@ const ChatPane = React.memo(function ChatPane() {
if (!user) {
return null
}
if (!chatOpenedOnce) {
return null
}
return (
<aside className="chat">
@ -71,13 +83,15 @@ const ChatPane = React.memo(function ChatPane() {
>
<div>
<h2 className="sr-only">{t('chat')}</h2>
{status === 'pending' && <LoadingSpinner delay={500} />}
{shouldDisplayPlaceholder && <Placeholder />}
<MessageList
messages={messages}
userId={user.id}
resetUnreadMessages={markMessagesAsRead}
/>
<Suspense fallback={<LoadingSpinner delay={500} />}>
{status === 'pending' && <LoadingSpinner delay={500} />}
{shouldDisplayPlaceholder && <Placeholder />}
<MessageList
messages={messages}
userId={user.id}
resetUnreadMessages={markMessagesAsRead}
/>
</Suspense>
</div>
</InfiniteScroll>
<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 SyncMenu from './sync-menu'
import SettingsMenu from './settings-menu'
import LeftMenuMask from './left-menu-mask'
import AccessibleModal from '../../../shared/components/accessible-modal'
import { Modal } from 'react-bootstrap'
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() {
const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
@ -29,11 +27,9 @@ export default function EditorLeftMenu() {
className={classNames('full-size', { shown: leftMenuShown })}
id="left-menu"
>
<DownloadMenu />
<ActionsMenu />
<SyncMenu />
<SettingsMenu />
<HelpMenu />
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<EditorLeftMenuBody />
</Suspense>
</Modal.Body>
</AccessibleModal>
{leftMenuShown && <LeftMenuMask />}

View file

@ -2,16 +2,19 @@ import { useTranslation } from 'react-i18next'
import FileTreeCreateNewDoc from './modes/file-tree-create-new-doc'
import FileTreeImportFromUrl from './modes/file-tree-import-from-url'
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 FileTreeCreateNameProvider from '../../contexts/file-tree-create-name'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../shared/context/file-tree-data-context'
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 FileTreeUploadDoc = lazy(() => import('./modes/file-tree-upload-doc'))
export default function FileTreeModalCreateFileBody() {
const { t } = useTranslation()
@ -86,7 +89,11 @@ export default function FileTreeModalCreateFileBody() {
</FileTreeCreateNameProvider>
)}
{newFileCreateMode === 'upload' && <FileTreeUploadDoc />}
{newFileCreateMode === 'upload' && (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<FileTreeUploadDoc />
</Suspense>
)}
{createFileModeModules.map(
({ 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 DiffView from './diff-view/diff-view'
import { HistoryProvider, useHistoryContext } from '../context/history-context'
import { HistoryProvider } from '../context/history-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { createPortal } from 'react-dom'
import HistoryFileTree from './history-file-tree'
import LoadingSpinner from '../../../shared/components/loading-spinner'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback'
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() {
const { view } = useLayoutContext()
const { updatesInfo } = useHistoryContext()
if (view !== 'history') {
return null
}
let content
if (updatesInfo.loadingState === 'loadingInitial') {
content = <LoadingSpinner />
} else {
content = (
<>
<DiffView />
<ChangeList />
</>
)
}
return (
<>
{fileTreeContainer
? createPortal(<HistoryFileTree />, fileTreeContainer)
: null}
<div className="history-react">{content}</div>
</>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<HistoryContent />
</Suspense>
)
}

View file

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

View file

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

View file

@ -1,11 +1,20 @@
import { Button, Modal, Grid } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import ShareModalBody from './share-modal-body'
import Icon from '../../../shared/components/icon'
import AccessibleModal from '../../../shared/components/accessible-modal'
import PropTypes from 'prop-types'
import { ReadOnlyTokenLink } from './link-sharing'
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({
show,
@ -28,7 +37,13 @@ export default function ShareProjectModalContent({
<Modal.Body className="modal-body-share">
<Grid fluid>
{isRestrictedTokenMember ? <ReadOnlyTokenLink /> : <ShareModalBody />}
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
{isRestrictedTokenMember ? (
<ReadOnlyTokenLink />
) : (
<ShareModalBody />
)}
</Suspense>
</Grid>
</Modal.Body>

View file

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

View file

@ -6,9 +6,8 @@ import {
useFigureModalContext,
useFigureModalExistingFigureContext,
} from './figure-modal-context'
import { FigureModalBody } from './figure-modal-body'
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 { ChangeSpec } from '@codemirror/state'
import {
@ -22,6 +21,9 @@ import { useTranslation } from 'react-i18next'
import useEventListener from '../../../../shared/hooks/use-event-listener'
import { prepareLines } from '../../utils/prepare-lines'
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() {
return (
@ -277,7 +279,9 @@ const FigureModalContent = () => {
</Modal.Header>
<Modal.Body>
<FigureModalBody />
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
<FigureModalBody />
</Suspense>
</Modal.Body>
<Modal.Footer>

View file

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

View file

@ -36,3 +36,22 @@ LoadingSpinner.propTypes = {
}
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/pager.less';
@import 'components/labels.less';
@import 'components/loading-spinner';
//@import "components/jumbotron.less";
@import 'components/thumbnails.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 {
color: #1b222c;
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/notifications.less';
@import 'components/labels.less';
@import 'components/loading-spinner';
@import 'components/thumbnails.less';
@import 'components/alerts.less';
@import 'components/progress-bars.less';

View file

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