Refactor compile-related code from PDF preview context provider into a separate class (#5341)

GitOrigin-RevId: 96b8bb527fa3d60a5fb84eee2b8f4fabc1726875
This commit is contained in:
Alf Eaton 2021-10-08 10:23:33 +01:00 committed by Copybot
parent 515180aeaa
commit b902bd9265
9 changed files with 403 additions and 386 deletions

View file

@ -3,7 +3,7 @@ import Icon from '../../../shared/components/icon'
import ControlledDropdown from '../../../shared/components/controlled-dropdown' import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context' import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo, useCallback } from 'react' import { memo } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
function PdfCompileButton() { function PdfCompileButton() {
@ -12,10 +12,10 @@ function PdfCompileButton() {
compiling, compiling,
draft, draft,
hasChanges, hasChanges,
recompile,
setAutoCompile, setAutoCompile,
setDraft, setDraft,
setStopOnValidationError, setStopOnValidationError,
startCompile,
stopCompile, stopCompile,
stopOnValidationError, stopOnValidationError,
recompileFromScratch, recompileFromScratch,
@ -25,10 +25,6 @@ function PdfCompileButton() {
const compileButtonLabel = compiling ? t('compiling') + '…' : t('recompile') const compileButtonLabel = compiling ? t('compiling') + '…' : t('recompile')
const startCompile = useCallback(() => {
recompile()
}, [recompile])
return ( return (
<ControlledDropdown <ControlledDropdown
className={classnames({ className={classnames({

View file

@ -6,7 +6,7 @@ import { memo } from 'react'
export function PdfLogsButtonContent({ export function PdfLogsButtonContent({
showLogs, showLogs,
logEntries, logEntries,
autoCompileLintingError, codeCheckFailed,
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -19,7 +19,7 @@ export function PdfLogsButtonContent({
) )
} }
if (autoCompileLintingError) { if (codeCheckFailed) {
return ( return (
<> <>
<Icon type="exclamation-triangle" /> <Icon type="exclamation-triangle" />
@ -59,7 +59,7 @@ export function PdfLogsButtonContent({
} }
PdfLogsButtonContent.propTypes = { PdfLogsButtonContent.propTypes = {
autoCompileLintingError: PropTypes.bool, codeCheckFailed: PropTypes.bool,
showLogs: PropTypes.bool, showLogs: PropTypes.bool,
logEntries: PropTypes.object, logEntries: PropTypes.object,
} }

View file

@ -6,8 +6,7 @@ import { sendMBOnce } from '../../../infrastructure/event-tracking'
function PdfLogsButton() { function PdfLogsButton() {
const { const {
autoCompileLintingError, codeCheckFailed,
stopOnValidationError,
error, error,
logEntries, logEntries,
showLogs, showLogs,
@ -19,7 +18,7 @@ function PdfLogsButton() {
return 'default' return 'default'
} }
if (autoCompileLintingError && stopOnValidationError) { if (codeCheckFailed) {
return 'danger' return 'danger'
} }
@ -34,7 +33,7 @@ function PdfLogsButton() {
} }
return 'default' return 'default'
}, [autoCompileLintingError, logEntries, showLogs, stopOnValidationError]) }, [codeCheckFailed, logEntries, showLogs])
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setShowLogs(value => { setShowLogs(value => {
@ -57,9 +56,7 @@ function PdfLogsButton() {
<PdfLogsButtonContent <PdfLogsButtonContent
showLogs={showLogs} showLogs={showLogs}
logEntries={logEntries} logEntries={logEntries}
autoCompileLintingError={ codeCheckFailed={codeCheckFailed}
autoCompileLintingError && stopOnValidationError
}
/> />
</Button> </Button>
) )

View file

@ -14,8 +14,7 @@ import ErrorBoundaryFallback from './error-boundary-fallback'
function PdfLogsViewer() { function PdfLogsViewer() {
const { const {
autoCompileLintingError, codeCheckFailed,
stopOnValidationError,
error, error,
logEntries, logEntries,
rawLog, rawLog,
@ -27,7 +26,7 @@ function PdfLogsViewer() {
return ( return (
<div className="logs-pane"> <div className="logs-pane">
<div className="logs-pane-content"> <div className="logs-pane-content">
{autoCompileLintingError && stopOnValidationError && ( {codeCheckFailed && (
<div className="log-entry"> <div className="log-entry">
<div className="log-entry-header log-entry-header-error"> <div className="log-entry-header log-entry-header-error">
<div className="log-entry-header-icon-container"> <div className="log-entry-header-icon-container">

View file

@ -9,32 +9,20 @@ import {
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import useScopeValue from '../../../shared/context/util/scope-value-hook' import useScopeValue from '../../../shared/context/util/scope-value-hook'
import { useProjectContext } from '../../../shared/context/project-context' import { useProjectContext } from '../../../shared/context/project-context'
import getMeta from '../../../utils/meta'
import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
import usePersistedState from '../../../shared/hooks/use-persisted-state' import usePersistedState from '../../../shared/hooks/use-persisted-state'
import { import {
buildLogEntryAnnotations, buildLogEntryAnnotations,
handleOutputFiles, handleOutputFiles,
} from '../util/output-files' } from '../util/output-files'
import { debounce } from 'lodash'
import { useIdeContext } from '../../../shared/context/ide-context'
import { import {
send, send,
sendMB, sendMB,
sendMBSampled, sendMBSampled,
} from '../../../infrastructure/event-tracking' } from '../../../infrastructure/event-tracking'
import { useEditorContext } from '../../../shared/context/editor-context' import { useEditorContext } from '../../../shared/context/editor-context'
import { isMainFile } from '../util/editor-files'
import useAbortController from '../../../shared/hooks/use-abort-controller' import useAbortController from '../../../shared/hooks/use-abort-controller'
import DocumentCompiler from '../util/compiler'
const AUTO_COMPILE_MAX_WAIT = 5000 import { useIdeContext } from '../../../shared/context/ide-context'
// We add a 1 second debounce to sending user changes to server if they aren't
// collaborating with anyone. This needs to be higher than that, and allow for
// client to server latency, otherwise we compile before the op reaches the server
// and then again on ack.
const AUTO_COMPILE_DEBOUNCE = 2000
const searchParams = new URLSearchParams(window.location.search)
export const PdfPreviewContext = createContext(undefined) export const PdfPreviewContext = createContext(undefined)
@ -45,7 +33,9 @@ PdfPreviewProvider.propTypes = {
export default function PdfPreviewProvider({ children }) { export default function PdfPreviewProvider({ children }) {
const ide = useIdeContext() const ide = useIdeContext()
const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext() const project = useProjectContext()
const projectId = project._id
const { hasPremiumCompile, isProjectOwner } = useEditorContext() const { hasPremiumCompile, isProjectOwner } = useEditorContext()
@ -65,10 +55,7 @@ export default function PdfPreviewProvider({ children }) {
const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations') const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations')
// the id of the CLSI server which ran the compile // the id of the CLSI server which ran the compile
const [clsiServerId, setClsiServerId] = useScopeValue('ide.clsiServerId') const [, setClsiServerId] = useScopeValue('ide.clsiServerId')
// the compile group (standard or priority)
const [compileGroup, setCompileGroup] = useScopeValue('ide.compileGroup')
// whether to display the editor and preview side-by-side or full-width ("flat") // whether to display the editor and preview side-by-side or full-width ("flat")
const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout') const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout')
@ -79,6 +66,9 @@ export default function PdfPreviewProvider({ children }) {
// whether a compile is in progress // whether a compile is in progress
const [compiling, setCompiling] = useState(false) const [compiling, setCompiling] = useState(false)
// data received in response to a compile request
const [data, setData] = useState()
// whether the project has been compiled yet // whether the project has been compiled yet
const [compiledOnce, setCompiledOnce] = useState(false) const [compiledOnce, setCompiledOnce] = useState(false)
@ -117,10 +107,7 @@ export default function PdfPreviewProvider({ children }) {
true true
) )
// the id of the currently open document in the editor // the Document currently open in the editor
const [currentDocId] = useScopeValue('editor.open_doc_id')
// the Document currently open in the editor?
const [currentDoc] = useScopeValue('editor.sharejs_doc') const [currentDoc] = useScopeValue('editor.sharejs_doc')
// whether the PDF view is hidden // whether the PDF view is hidden
@ -137,6 +124,35 @@ export default function PdfPreviewProvider({ children }) {
const { signal } = useAbortController() const { signal } = useAbortController()
// the document compiler
const [compiler] = useState(() => {
return new DocumentCompiler({
project,
setChangedAt,
setCompiling,
setData,
setError,
signal,
})
})
// clean up the compiler on unmount
useEffect(() => {
return () => {
compiler.destroy()
}
}, [compiler])
// keep currentDoc in sync with the compiler
useEffect(() => {
compiler.currentDoc = currentDoc
}, [compiler, currentDoc])
// keep draft setting in sync with the compiler
useEffect(() => {
compiler.draft = draft
}, [compiler, draft])
// pass the "uncompiled" value up into the scope for use outside this context provider // pass the "uncompiled" value up into the scope for use outside this context provider
useEffect(() => { useEffect(() => {
setUncompiled(changedAt > 0) setUncompiled(changedAt > 0)
@ -151,46 +167,26 @@ export default function PdfPreviewProvider({ children }) {
[_setAutoCompile] [_setAutoCompile]
) )
// parse the text of the current doc in the editor // always compile the PDF once after opening the project, after the doc has loaded
// if it contains "\documentclass" then use this as the root doc useEffect(() => {
const getRootDocOverrideId = useCallback(() => { if (!compiledOnce && currentDoc) {
if (currentDocId === rootDocId) { setCompiledOnce(true)
return null // no need to override when in the root doc itself compiler.compile({ isAutoCompileOnLoad: true })
} }
}, [compiledOnce, currentDoc, compiler])
if (currentDoc) {
const doc = currentDoc.getSnapshot()
if (doc) {
return isMainFile(doc)
}
}
return null
}, [currentDoc, currentDocId, rootDocId])
// TODO: remove this?
const sendCompileMetrics = useCallback(() => {
if (compiledOnce && !error && !window.user.alphaProgram) {
const metadata = {
errors: logEntries.errors.length,
warnings: logEntries.warnings.length,
typesetting: logEntries.typesetting.length,
newPdfPreview: true,
}
sendMBSampled('compile-result', metadata, 0.01)
}
}, [compiledOnce, error, logEntries])
// handle the data returned from a compile request // handle the data returned from a compile request
const handleCompileData = useCallback( // note: this should _only_ run when `data` changes,
(data, options) => { // the other dependencies must all be static
useEffect(() => {
if (data) {
if (data.clsiServerId) { if (data.clsiServerId) {
setClsiServerId(data.clsiServerId) setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
compiler.clsiServerId = data.clsiServerId
} }
if (data.compileGroup) { if (data.compileGroup) {
setCompileGroup(data.compileGroup) compiler.compileGroup = data.compileGroup
} }
if (data.outputFiles) { if (data.outputFiles) {
@ -203,13 +199,27 @@ export default function PdfPreviewProvider({ children }) {
setPdfDownloadUrl(result.pdfDownloadUrl) setPdfDownloadUrl(result.pdfDownloadUrl)
setPdfUrl(result.pdfUrl) setPdfUrl(result.pdfUrl)
setRawLog(result.log) setRawLog(result.log)
// sample compile stats for real users
if (!window.user.alphaProgram && data.status === 'success') {
sendMBSampled(
'compile-result',
{
errors: result.logEntries.errors.length,
warnings: result.logEntries.warnings.length,
typesetting: result.logEntries.typesetting.length,
newPdfPreview: true, // TODO: is this useful?
},
0.01
)
}
}) })
} }
switch (data.status) { switch (data.status) {
case 'success': case 'success':
setError(undefined) setError(undefined)
setShowLogs(false) // TODO: always? setShowLogs(false)
break break
case 'clsi-maintenance': case 'clsi-maintenance':
@ -238,7 +248,7 @@ export default function PdfPreviewProvider({ children }) {
break break
case 'autocompile-backoff': case 'autocompile-backoff':
if (!options.isAutoCompileOnLoad) { if (!data.options.isAutoCompileOnLoad) {
setError('autocompile-disabled') setError('autocompile-disabled')
setAutoCompile(false) setAutoCompile(false)
sendMB('autocompile-rate-limited', { hasPremiumCompile }) sendMB('autocompile-rate-limited', { hasPremiumCompile })
@ -258,97 +268,21 @@ export default function PdfPreviewProvider({ children }) {
setError('error') setError('error')
break break
} }
}, }
[ }, [
hasPremiumCompile, compiler,
ide.fileTreeManager, data,
isProjectOwner, ide,
projectId, hasPremiumCompile,
setAutoCompile, isProjectOwner,
setClsiServerId, projectId,
setCompileGroup, setAutoCompile,
setLogEntries, setClsiServerId,
setLogEntryAnnotations, setLogEntries,
setPdfDownloadUrl, setLogEntryAnnotations,
setPdfUrl, setPdfDownloadUrl,
] setPdfUrl,
) ])
const buildCompileParams = useCallback(
options => {
const params = new URLSearchParams()
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
params.set('auto_compile', 'true')
}
if (getMeta('ol-enablePdfCaching')) {
params.set('enable_pdf_caching', 'true')
}
if (searchParams.get('file_line_errors') === 'true') {
params.file_line_errors = 'true'
}
return params
},
[clsiServerId]
)
// run a compile
const recompile = useCallback(
(options = {}) => {
if (compiling) {
return
}
sendMBSampled('editor-recompile-sampled', options)
setChangedAt(0) // NOTE: this sets uncompiled to false
setCompiling(true)
setValidationIssues(undefined)
window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this?
postJSON(`/project/${projectId}/compile?${buildCompileParams(options)}`, {
body: {
rootDoc_id: getRootDocOverrideId(),
draft,
check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
// use incremental compile for all users but revert to a full compile
// if there was previously a server error
incrementalCompilesEnabled: !error,
},
signal,
})
.then(data => {
handleCompileData(data, options)
})
.catch(error => {
// console.error(error)
setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
})
.finally(() => {
setCompiling(false)
sendCompileMetrics()
})
},
[
compiling,
projectId,
buildCompileParams,
getRootDocOverrideId,
draft,
error,
handleCompileData,
sendCompileMetrics,
signal,
]
)
// switch to logs if there's an error // switch to logs if there's an error
useEffect(() => { useEffect(() => {
@ -360,7 +294,7 @@ export default function PdfPreviewProvider({ children }) {
// recompile on key press // recompile on key press
useEffect(() => { useEffect(() => {
const listener = event => { const listener = event => {
recompile(event.detail) compiler.compile(event.detail)
} }
window.addEventListener('pdf:recompile', listener) window.addEventListener('pdf:recompile', listener)
@ -368,73 +302,40 @@ export default function PdfPreviewProvider({ children }) {
return () => { return () => {
window.removeEventListener('pdf:recompile', listener) window.removeEventListener('pdf:recompile', listener)
} }
}, [recompile]) }, [compiler])
// always compile the PDF once, when joining the project
useEffect(() => {
const listener = () => {
if (!compiledOnce) {
setCompiledOnce(true)
recompile({ isAutoCompileOnLoad: true })
}
}
window.addEventListener('project:joined', listener)
return () => {
window.removeEventListener('project:joined', listener)
}
}, [compiledOnce, recompile])
// whether there has been an autocompile linting error, if syntax validation is switched on // whether there has been an autocompile linting error, if syntax validation is switched on
const autoCompileLintingError = Boolean( const autoCompileLintingError = Boolean(
autoCompile && syntaxValidation && hasLintingError autoCompile && syntaxValidation && hasLintingError
) )
// the project has visible changes const codeCheckFailed = stopOnValidationError && autoCompileLintingError
// show that the project has pending changes
const hasChanges = Boolean( const hasChanges = Boolean(
autoCompile && autoCompile && uncompiled && compiledOnce && !codeCheckFailed
uncompiled &&
compiledOnce &&
!(stopOnValidationError && autoCompileLintingError)
) )
// the project is available for auto-compiling // the project is available for auto-compiling
const canAutoCompile = Boolean( const canAutoCompile = Boolean(
autoCompile && autoCompile && !compiling && !pdfHidden && !codeCheckFailed
!compiling &&
!pdfHidden &&
!(stopOnValidationError && autoCompileLintingError)
) )
// a debounced wrapper around the recompile function, used for auto-compile // call the debounced autocompile function if the project is available for auto-compiling and it has changed
const [debouncedAutoCompile] = useState(() => {
return debounce(
() => {
recompile({ isAutoCompileOnChange: true })
},
AUTO_COMPILE_DEBOUNCE,
{
maxWait: AUTO_COMPILE_MAX_WAIT,
}
)
})
// call the debounced recompile function if the project is available for auto-compiling and it has changed
useEffect(() => { useEffect(() => {
if (canAutoCompile && changedAt > 0) { if (canAutoCompile && changedAt > 0) {
debouncedAutoCompile() compiler.debouncedAutoCompile()
} else { } else {
debouncedAutoCompile.cancel() compiler.debouncedAutoCompile.cancel()
} }
}, [canAutoCompile, debouncedAutoCompile, recompile, changedAt]) }, [compiler, canAutoCompile, changedAt])
// cancel debounced recompile on unmount // cancel debounced recompile on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
debouncedAutoCompile.cancel() compiler.debouncedAutoCompile.cancel()
} }
}, [debouncedAutoCompile]) }, [compiler])
// record doc changes when notified by the editor // record doc changes when notified by the editor
useEffect(() => { useEffect(() => {
@ -451,52 +352,31 @@ export default function PdfPreviewProvider({ children }) {
} }
}, []) }, [])
// send a request to stop the current compile // start a compile manually
const startCompile = useCallback(() => {
compiler.compile()
}, [compiler])
// stop a compile manually
const stopCompile = useCallback(() => { const stopCompile = useCallback(() => {
// TODO: stoppingCompile state? compiler.stopCompile()
}, [compiler])
const params = new URLSearchParams()
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
return postJSON(`/project/${projectId}/compile/stop?${params}`, { signal })
.catch(error => {
setError(error)
})
.finally(() => {
setCompiling(false)
})
}, [projectId, clsiServerId, signal])
// clear the compile cache
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
setClearingCache(true) setClearingCache(true)
const params = new URLSearchParams() return compiler.clearCache().finally(() => {
setClearingCache(false)
if (clsiServerId) { })
params.set('clsiserverid', clsiServerId) }, [compiler])
}
return deleteJSON(`/project/${projectId}/output?${params}`, { signal })
.catch(error => {
console.error(error)
setError('clear-cache')
})
.finally(() => {
setClearingCache(false)
})
}, [clsiServerId, projectId, setError, signal])
// clear the cache then run a compile, triggered by a menu item // clear the cache then run a compile, triggered by a menu item
const recompileFromScratch = useCallback(() => { const recompileFromScratch = useCallback(() => {
setClearingCache(true)
clearCache().then(() => { clearCache().then(() => {
setClearingCache(false) compiler.compile()
recompile()
}) })
}, [clearCache, recompile]) }, [clearCache, compiler])
// switch to either side-by-side or flat (full-width) layout // switch to either side-by-side or flat (full-width) layout
const switchLayout = useCallback(() => { const switchLayout = useCallback(() => {
@ -512,12 +392,9 @@ export default function PdfPreviewProvider({ children }) {
const value = useMemo(() => { const value = useMemo(() => {
return { return {
autoCompile, autoCompile,
autoCompileLintingError, codeCheckFailed,
clearCache, clearCache,
clearingCache, clearingCache,
clsiServerId,
compileGroup,
compiledOnce,
compiling, compiling,
draft, draft,
error, error,
@ -529,23 +406,14 @@ export default function PdfPreviewProvider({ children }) {
pdfLayout, pdfLayout,
pdfUrl, pdfUrl,
rawLog, rawLog,
recompile,
recompileFromScratch, recompileFromScratch,
setAutoCompile, setAutoCompile,
setClsiServerId,
setCompileGroup,
setCompiledOnce,
setDraft, setDraft,
setError, setHasLintingError, // only for stories
setHasLintingError, // for story
setLogEntries,
setPdfDownloadUrl,
setPdfLayout,
setPdfUrl,
setShowLogs, setShowLogs,
setStopOnValidationError, setStopOnValidationError,
setUiView,
showLogs, showLogs,
startCompile,
stopCompile, stopCompile,
stopOnValidationError, stopOnValidationError,
switchLayout, switchLayout,
@ -554,12 +422,9 @@ export default function PdfPreviewProvider({ children }) {
} }
}, [ }, [
autoCompile, autoCompile,
autoCompileLintingError, codeCheckFailed,
clearCache, clearCache,
clearingCache, clearingCache,
clsiServerId,
compileGroup,
compiledOnce,
compiling, compiling,
draft, draft,
error, error,
@ -571,22 +436,13 @@ export default function PdfPreviewProvider({ children }) {
pdfLayout, pdfLayout,
pdfUrl, pdfUrl,
rawLog, rawLog,
recompile,
recompileFromScratch, recompileFromScratch,
setAutoCompile, setAutoCompile,
setClsiServerId,
setCompileGroup,
setCompiledOnce,
setDraft, setDraft,
setError, setHasLintingError, // only for stories
setHasLintingError,
setLogEntries,
setPdfDownloadUrl,
setPdfLayout,
setPdfUrl,
setStopOnValidationError, setStopOnValidationError,
setUiView,
showLogs, showLogs,
startCompile,
stopCompile, stopCompile,
stopOnValidationError, stopOnValidationError,
switchLayout, switchLayout,
@ -604,12 +460,9 @@ export default function PdfPreviewProvider({ children }) {
PdfPreviewContext.Provider.propTypes = { PdfPreviewContext.Provider.propTypes = {
value: PropTypes.shape({ value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired, autoCompile: PropTypes.bool.isRequired,
autoCompileLintingError: PropTypes.bool.isRequired,
clearCache: PropTypes.func.isRequired, clearCache: PropTypes.func.isRequired,
clearingCache: PropTypes.bool.isRequired, clearingCache: PropTypes.bool.isRequired,
clsiServerId: PropTypes.string, codeCheckFailed: PropTypes.bool.isRequired,
compileGroup: PropTypes.string,
compiledOnce: PropTypes.bool.isRequired,
compiling: PropTypes.bool.isRequired, compiling: PropTypes.bool.isRequired,
draft: PropTypes.bool.isRequired, draft: PropTypes.bool.isRequired,
error: PropTypes.string, error: PropTypes.string,
@ -621,23 +474,14 @@ PdfPreviewContext.Provider.propTypes = {
pdfLayout: PropTypes.string, pdfLayout: PropTypes.string,
pdfUrl: PropTypes.string, pdfUrl: PropTypes.string,
rawLog: PropTypes.string, rawLog: PropTypes.string,
recompile: PropTypes.func.isRequired,
recompileFromScratch: PropTypes.func.isRequired, recompileFromScratch: PropTypes.func.isRequired,
setAutoCompile: PropTypes.func.isRequired, setAutoCompile: PropTypes.func.isRequired,
setClsiServerId: PropTypes.func.isRequired,
setCompileGroup: PropTypes.func.isRequired,
setCompiledOnce: PropTypes.func.isRequired,
setDraft: PropTypes.func.isRequired, setDraft: PropTypes.func.isRequired,
setError: PropTypes.func.isRequired,
setHasLintingError: PropTypes.func.isRequired, // only for storybook setHasLintingError: PropTypes.func.isRequired, // only for storybook
setLogEntries: PropTypes.func.isRequired,
setPdfDownloadUrl: PropTypes.func.isRequired,
setPdfLayout: PropTypes.func.isRequired,
setPdfUrl: PropTypes.func.isRequired,
setShowLogs: PropTypes.func.isRequired, setShowLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired, setStopOnValidationError: PropTypes.func.isRequired,
setUiView: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired, showLogs: PropTypes.bool.isRequired,
startCompile: PropTypes.func.isRequired,
stopCompile: PropTypes.func.isRequired, stopCompile: PropTypes.func.isRequired,
stopOnValidationError: PropTypes.bool.isRequired, stopOnValidationError: PropTypes.bool.isRequired,
switchLayout: PropTypes.func.isRequired, switchLayout: PropTypes.func.isRequired,

View file

@ -0,0 +1,179 @@
import { isMainFile } from './editor-files'
import getMeta from '../../../utils/meta'
import { sendMBSampled } from '../../../infrastructure/event-tracking'
import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
import { debounce } from 'lodash'
const AUTO_COMPILE_MAX_WAIT = 5000
// We add a 1 second debounce to sending user changes to server if they aren't
// collaborating with anyone. This needs to be higher than that, and allow for
// client to server latency, otherwise we compile before the op reaches the server
// and then again on ack.
const AUTO_COMPILE_DEBOUNCE = 2000
const searchParams = new URLSearchParams(window.location.search)
export default class DocumentCompiler {
constructor({
project,
setChangedAt,
setCompiling,
setData,
setError,
signal,
}) {
this.project = project
this.setChangedAt = setChangedAt
this.setCompiling = setCompiling
this.setData = setData
this.setError = setError
this.signal = signal
this.clsiServerId = null
this.currentDoc = null
this.error = undefined
this.timer = 0
this.debouncedAutoCompile = debounce(
() => {
this.compile({ isAutoCompileOnChange: true })
},
AUTO_COMPILE_DEBOUNCE,
{
maxWait: AUTO_COMPILE_MAX_WAIT,
}
)
}
destroy() {
this.signal.abort()
this.debouncedAutoCompile.cancel()
}
// The main "compile" function.
// Call this directly to run a compile now, otherwise call debouncedAutoCompile.
async compile(options = {}) {
// set "compiling" to true (in the React component's state), and return if it was already true
let wasCompiling
this.setCompiling(oldValue => {
wasCompiling = oldValue
return true
})
if (wasCompiling) {
return
}
try {
// log a sample of the compile requests
sendMBSampled('editor-recompile-sampled', options)
// reset values
this.setChangedAt(0)
this.validationIssues = undefined
window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this?
const params = this.buildQueryParams()
this.addCompileParams(params, options)
const data = await postJSON(
`/project/${this.project._id}/compile?${params}`,
{
body: {
rootDoc_id: this.getRootDocOverrideId(),
draft: this.draft,
check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
// use incremental compile for all users but revert to a full compile
// if there was previously a server error
incrementalCompilesEnabled: !this.error,
},
signal: this.signal,
}
)
data.options = options
this.setData(data)
} catch (error) {
console.error(error)
this.setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
} finally {
this.setCompiling(false)
}
}
// parse the text of the current doc in the editor
// if it contains "\documentclass" then use this as the root doc
getRootDocOverrideId() {
// only override when not in the root doc itself
if (this.currentDoc.doc_id !== this.project.rootDoc_id) {
const snapshot = this.currentDoc.getSnapshot()
if (snapshot && isMainFile(snapshot)) {
return this.currentDoc.doc_id
}
}
return null
}
// build the query parameters added to all requests
buildQueryParams() {
const params = new URLSearchParams()
// the id of the CLSI server that processed the previous compile request
if (this.clsiServerId) {
params.set('clsiserverid', this.clsiServerId)
}
return params
}
// add extra query parameters to the compile request
addCompileParams(params, options) {
// tell the server whether this is an automatic or manual compile request
if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
params.set('auto_compile', 'true')
}
// use the feature flag to enable PDF caching in a ServiceWorker
if (getMeta('ol-enablePdfCaching')) {
params.set('enable_pdf_caching', 'true')
}
// use the feature flag to enable "file line errors"
if (searchParams.get('file_line_errors') === 'true') {
params.file_line_errors = 'true'
}
}
// send a request to stop the current compile
stopCompile() {
// NOTE: no stoppingCompile state, as this should happen fairly quickly
// and doesn't matter if it runs twice.
const params = this.buildQueryParams()
return postJSON(`/project/${this.project._id}/compile/stop?${params}`, {
signal: this.signal,
})
.catch(error => {
console.error(error)
this.setError('error')
})
.finally(() => {
this.setCompiling(false)
})
}
clearCache() {
const params = this.buildQueryParams()
return deleteJSON(`/project/${this.project._id}/output?${params}`, {
signal: this.signal,
}).catch(error => {
console.error(error)
this.setError('clear-cache')
})
}
}

View file

@ -109,6 +109,6 @@ export const Interactive = () => {
<PdfJsViewer url="/build/output.pdf" /> <PdfJsViewer url="/build/output.pdf" />
<Inner /> <Inner />
</div>, </div>,
{ scope: { project } } { project }
) )
} }

View file

@ -47,6 +47,12 @@ const scope = {
}, },
hasLintingError: false, hasLintingError: false,
$applyAsync: () => {}, $applyAsync: () => {},
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
} }
const dispatchProjectJoined = () => { const dispatchProjectJoined = () => {

View file

@ -9,7 +9,7 @@ const outputFiles = [
{ {
path: 'output.pdf', path: 'output.pdf',
build: '123', build: '123',
// url: 'about:blank', // TODO: PDF URL to render url: '/build/output.pdf', // TODO: PDF URL to render
type: 'pdf', type: 'pdf',
}, },
{ {
@ -119,6 +119,18 @@ const storeAndFireEvent = (key, value) => {
fireEvent(window, new StorageEvent('storage', { key })) fireEvent(window, new StorageEvent('storage', { key }))
} }
const scope = {
settings: {
syntaxValidation: false,
},
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
}
describe('<PdfPreview/>', function () { describe('<PdfPreview/>', function () {
var clock var clock
@ -138,50 +150,31 @@ describe('<PdfPreview/>', function () {
localStorage.clear() localStorage.clear()
}) })
it('renders the PDF preview', function () { it('renders the PDF preview', async function () {
renderWithEditorContext(<PdfPreview />)
screen.getByRole('button', { name: 'Recompile' })
})
it('runs a compile only on the first project:joined event', async function () {
mockCompile() mockCompile()
mockBuildFile() mockBuildFile()
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
// fire another project:joined event => no compile
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(3)
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
}) })
it('runs a compile when the Recompile button is pressed', async function () { it('runs a compile when the Recompile button is pressed', async function () {
mockCompile() mockCompile()
mockBuildFile() mockBuildFile()
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
// press the Recompile button => compile // press the Recompile button => compile
const button = screen.getByRole('button', { name: 'Recompile' }) const button = screen.getByRole('button', { name: 'Recompile' })
button.click() button.click()
screen.getByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(6) expect(fetchMock.calls()).to.have.length(6)
@ -191,12 +184,10 @@ describe('<PdfPreview/>', function () {
mockCompile() mockCompile()
mockBuildFile() mockBuildFile()
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
// switch on auto compile // switch on auto compile
@ -206,7 +197,7 @@ describe('<PdfPreview/>', function () {
fireEvent(window, new CustomEvent('doc:changed')) fireEvent(window, new CustomEvent('doc:changed'))
clock.tick(2000) // AUTO_COMPILE_DEBOUNCE clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
await screen.findByText('Compiling…') await screen.findByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(6) expect(fetchMock.calls()).to.have.length(6)
@ -216,17 +207,14 @@ describe('<PdfPreview/>', function () {
mockCompile() mockCompile()
mockBuildFile() mockBuildFile()
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
// make sure auto compile is switched off // make sure auto compile is switched off
storeAndFireEvent('autocompile_enabled:project123', false) storeAndFireEvent('autocompile_enabled:project123', false)
screen.getByRole('button', { name: 'Recompile' })
// fire a doc:changed event => no compile // fire a doc:changed event => no compile
fireEvent(window, new CustomEvent('doc:changed')) fireEvent(window, new CustomEvent('doc:changed'))
@ -242,21 +230,19 @@ describe('<PdfPreview/>', function () {
renderWithEditorContext(<PdfPreview />, { renderWithEditorContext(<PdfPreview />, {
scope: { scope: {
...scope,
'settings.syntaxValidation': true, // enable linting in the editor 'settings.syntaxValidation': true, // enable linting in the editor
hasLintingError: true, // mock a linting error hasLintingError: true, // mock a linting error
}, },
}) })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
// switch on auto compile and syntax checking // switch on auto compile and syntax checking
storeAndFireEvent('autocompile_enabled:project123', true) storeAndFireEvent('autocompile_enabled:project123', true)
storeAndFireEvent('stop_on_validation_error:project123', true) storeAndFireEvent('stop_on_validation_error:project123', true)
screen.getByRole('button', { name: 'Recompile' })
// fire a doc:changed event => no compile // fire a doc:changed event => no compile
fireEvent(window, new CustomEvent('doc:changed')) fireEvent(window, new CustomEvent('doc:changed'))
@ -270,13 +256,12 @@ describe('<PdfPreview/>', function () {
it('displays an error message if there was a compile error', async function () { it('displays an error message if there was a compile error', async function () {
mockCompileError('compile-in-progress') mockCompileError('compile-in-progress')
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
screen.getByText( screen.getByText(
'Please wait for your other compile to finish before trying again.' 'Please wait for your other compile to finish before trying again.'
) )
@ -285,27 +270,6 @@ describe('<PdfPreview/>', function () {
expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
}) })
it('disables the view logs button if there is a compile error', async function () {
mockCompileError()
mockBuildFile()
renderWithEditorContext(<PdfPreview />)
const logsButton = screen.getByRole('button', { name: 'View logs' })
expect(logsButton.hasAttribute('disabled')).to.be.false
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
expect(logsButton.hasAttribute('disabled')).to.be.false
await screen.findByRole('button', { name: 'Recompile' })
expect(logsButton.hasAttribute('disabled')).to.be.true
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
})
it('displays error messages if there were validation problems', async function () { it('displays error messages if there were validation problems', async function () {
const validationProblems = { const validationProblems = {
sizeCheck: { sizeCheck: {
@ -327,13 +291,12 @@ describe('<PdfPreview/>', function () {
mockValidationProblems(validationProblems) mockValidationProblems(validationProblems)
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
// fire a project:joined event => compile // wait for "compile on load" to finish
screen.getByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Compiling…' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
screen.getByText('Project too large') screen.getByText('Project too large')
screen.getByText('Unknown main document') screen.getByText('Unknown main document')
screen.getByText('Conflicting Paths Found') screen.getByText('Conflicting Paths Found')
@ -347,24 +310,18 @@ describe('<PdfPreview/>', function () {
mockBuildFile() mockBuildFile()
mockClearCache() mockClearCache()
renderWithEditorContext(<PdfPreview />) renderWithEditorContext(<PdfPreview />, { scope })
const logsButton = screen.getByRole('button', { name: 'View logs' }) // wait for "compile on load" to finish
logsButton.click() await screen.findByRole('button', { name: 'Compiling…' })
let clearCacheButton = screen.getByRole('button', {
name: 'Clear cached files',
})
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
await screen.findByRole('button', { name: 'Recompile' }) await screen.findByRole('button', { name: 'Recompile' })
const logsButton = screen.getByRole('button', {
name: 'This project has an error',
})
logsButton.click() logsButton.click()
clearCacheButton = await screen.findByRole('button', {
const clearCacheButton = await screen.findByRole('button', {
name: 'Clear cached files', name: 'Clear cached files',
}) })
expect(clearCacheButton.hasAttribute('disabled')).to.be.false expect(clearCacheButton.hasAttribute('disabled')).to.be.false
@ -379,4 +336,43 @@ describe('<PdfPreview/>', function () {
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
}) })
it('handle "recompile from scratch"', async function () {
mockCompile()
mockBuildFile()
mockClearCache()
renderWithEditorContext(<PdfPreview />, { scope })
// wait for "compile on load" to finish
await screen.findByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
// show the logs UI
const logsButton = screen.getByRole('button', {
name: 'This project has an error',
})
logsButton.click()
const clearCacheButton = await screen.findByRole('button', {
name: 'Clear cached files',
})
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
const recompileFromScratch = screen.getByRole('menuitem', {
name: 'Recompile from scratch',
hidden: true,
})
recompileFromScratch.click()
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
// wait for compile to finish
await screen.findByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/project/:projectId/output')).to.be.true
expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
})
}) })