Przeglądaj źródła

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

GitOrigin-RevId: 96b8bb527fa3d60a5fb84eee2b8f4fabc1726875
Alf Eaton 4 lat temu
rodzic
commit
b902bd9265

+ 2 - 6
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js

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

+ 3 - 3
services/web/frontend/js/features/pdf-preview/components/pdf-logs-button-content.js

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

+ 4 - 7
services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js

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

+ 2 - 3
services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js

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

+ 119 - 275
services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js

@@ -9,32 +9,20 @@ import {
 import PropTypes from 'prop-types'
 import useScopeValue from '../../../shared/context/util/scope-value-hook'
 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 {
   buildLogEntryAnnotations,
   handleOutputFiles,
 } from '../util/output-files'
-import { debounce } from 'lodash'
-import { useIdeContext } from '../../../shared/context/ide-context'
 import {
   send,
   sendMB,
   sendMBSampled,
 } from '../../../infrastructure/event-tracking'
 import { useEditorContext } from '../../../shared/context/editor-context'
-import { isMainFile } from '../util/editor-files'
 import useAbortController from '../../../shared/hooks/use-abort-controller'
-
-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)
+import DocumentCompiler from '../util/compiler'
+import { useIdeContext } from '../../../shared/context/ide-context'
 
 export const PdfPreviewContext = createContext(undefined)
 
@@ -45,7 +33,9 @@ PdfPreviewProvider.propTypes = {
 export default function PdfPreviewProvider({ children }) {
   const ide = useIdeContext()
 
-  const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext()
+  const project = useProjectContext()
+
+  const projectId = project._id
 
   const { hasPremiumCompile, isProjectOwner } = useEditorContext()
 
@@ -65,10 +55,7 @@ export default function PdfPreviewProvider({ children }) {
   const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations')
 
   // the id of the CLSI server which ran the compile
-  const [clsiServerId, setClsiServerId] = useScopeValue('ide.clsiServerId')
-
-  // the compile group (standard or priority)
-  const [compileGroup, setCompileGroup] = useScopeValue('ide.compileGroup')
+  const [, setClsiServerId] = useScopeValue('ide.clsiServerId')
 
   // whether to display the editor and preview side-by-side or full-width ("flat")
   const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout')
@@ -79,6 +66,9 @@ export default function PdfPreviewProvider({ children }) {
   // whether a compile is in progress
   const [compiling, setCompiling] = useState(false)
 
+  // data received in response to a compile request
+  const [data, setData] = useState()
+
   // whether the project has been compiled yet
   const [compiledOnce, setCompiledOnce] = useState(false)
 
@@ -117,10 +107,7 @@ export default function PdfPreviewProvider({ children }) {
     true
   )
 
-  // the id of the currently open document in the editor
-  const [currentDocId] = useScopeValue('editor.open_doc_id')
-
-  // the Document currently open in the editor?
+  // the Document currently open in the editor
   const [currentDoc] = useScopeValue('editor.sharejs_doc')
 
   // whether the PDF view is hidden
@@ -137,6 +124,35 @@ export default function PdfPreviewProvider({ children }) {
 
   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
   useEffect(() => {
     setUncompiled(changedAt > 0)
@@ -151,46 +167,26 @@ export default function PdfPreviewProvider({ children }) {
     [_setAutoCompile]
   )
 
-  // parse the text of the current doc in the editor
-  // if it contains "\documentclass" then use this as the root doc
-  const getRootDocOverrideId = useCallback(() => {
-    if (currentDocId === rootDocId) {
-      return null // no need to override when in the root doc itself
-    }
-
-    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)
+  // always compile the PDF once after opening the project, after the doc has loaded
+  useEffect(() => {
+    if (!compiledOnce && currentDoc) {
+      setCompiledOnce(true)
+      compiler.compile({ isAutoCompileOnLoad: true })
     }
-  }, [compiledOnce, error, logEntries])
+  }, [compiledOnce, currentDoc, compiler])
 
   // handle the data returned from a compile request
-  const handleCompileData = useCallback(
-    (data, options) => {
+  // note: this should _only_ run when `data` changes,
+  // the other dependencies must all be static
+  useEffect(() => {
+    if (data) {
       if (data.clsiServerId) {
-        setClsiServerId(data.clsiServerId)
+        setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
+        compiler.clsiServerId = data.clsiServerId
       }
 
       if (data.compileGroup) {
-        setCompileGroup(data.compileGroup)
+        compiler.compileGroup = data.compileGroup
       }
 
       if (data.outputFiles) {
@@ -203,13 +199,27 @@ export default function PdfPreviewProvider({ children }) {
           setPdfDownloadUrl(result.pdfDownloadUrl)
           setPdfUrl(result.pdfUrl)
           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) {
         case 'success':
           setError(undefined)
-          setShowLogs(false) // TODO: always?
+          setShowLogs(false)
           break
 
         case 'clsi-maintenance':
@@ -238,7 +248,7 @@ export default function PdfPreviewProvider({ children }) {
           break
 
         case 'autocompile-backoff':
-          if (!options.isAutoCompileOnLoad) {
+          if (!data.options.isAutoCompileOnLoad) {
             setError('autocompile-disabled')
             setAutoCompile(false)
             sendMB('autocompile-rate-limited', { hasPremiumCompile })
@@ -258,97 +268,21 @@ export default function PdfPreviewProvider({ children }) {
           setError('error')
           break
       }
-    },
-    [
-      hasPremiumCompile,
-      ide.fileTreeManager,
-      isProjectOwner,
-      projectId,
-      setAutoCompile,
-      setClsiServerId,
-      setCompileGroup,
-      setLogEntries,
-      setLogEntryAnnotations,
-      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,
-    ]
-  )
+    }
+  }, [
+    compiler,
+    data,
+    ide,
+    hasPremiumCompile,
+    isProjectOwner,
+    projectId,
+    setAutoCompile,
+    setClsiServerId,
+    setLogEntries,
+    setLogEntryAnnotations,
+    setPdfDownloadUrl,
+    setPdfUrl,
+  ])
 
   // switch to logs if there's an error
   useEffect(() => {
@@ -360,7 +294,7 @@ export default function PdfPreviewProvider({ children }) {
   // recompile on key press
   useEffect(() => {
     const listener = event => {
-      recompile(event.detail)
+      compiler.compile(event.detail)
     }
 
     window.addEventListener('pdf:recompile', listener)
@@ -368,73 +302,40 @@ export default function PdfPreviewProvider({ children }) {
     return () => {
       window.removeEventListener('pdf:recompile', listener)
     }
-  }, [recompile])
-
-  // 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])
+  }, [compiler])
 
   // whether there has been an autocompile linting error, if syntax validation is switched on
   const autoCompileLintingError = Boolean(
     autoCompile && syntaxValidation && hasLintingError
   )
 
-  // the project has visible changes
+  const codeCheckFailed = stopOnValidationError && autoCompileLintingError
+
+  // show that the project has pending changes
   const hasChanges = Boolean(
-    autoCompile &&
-      uncompiled &&
-      compiledOnce &&
-      !(stopOnValidationError && autoCompileLintingError)
+    autoCompile && uncompiled && compiledOnce && !codeCheckFailed
   )
 
   // the project is available for auto-compiling
   const canAutoCompile = Boolean(
-    autoCompile &&
-      !compiling &&
-      !pdfHidden &&
-      !(stopOnValidationError && autoCompileLintingError)
+    autoCompile && !compiling && !pdfHidden && !codeCheckFailed
   )
 
-  // a debounced wrapper around the recompile function, used for auto-compile
-  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
+  // call the debounced autocompile function if the project is available for auto-compiling and it has changed
   useEffect(() => {
     if (canAutoCompile && changedAt > 0) {
-      debouncedAutoCompile()
+      compiler.debouncedAutoCompile()
     } else {
-      debouncedAutoCompile.cancel()
+      compiler.debouncedAutoCompile.cancel()
     }
-  }, [canAutoCompile, debouncedAutoCompile, recompile, changedAt])
+  }, [compiler, canAutoCompile, changedAt])
 
   // cancel debounced recompile on unmount
   useEffect(() => {
     return () => {
-      debouncedAutoCompile.cancel()
+      compiler.debouncedAutoCompile.cancel()
     }
-  }, [debouncedAutoCompile])
+  }, [compiler])
 
   // record doc changes when notified by the editor
   useEffect(() => {
@@ -451,52 +352,31 @@ export default function PdfPreviewProvider({ children }) {
     }
   }, [])
 
-  // send a request to stop the current compile
-  const stopCompile = useCallback(() => {
-    // TODO: stoppingCompile state?
-
-    const params = new URLSearchParams()
+  // start a compile manually
+  const startCompile = useCallback(() => {
+    compiler.compile()
+  }, [compiler])
 
-    if (clsiServerId) {
-      params.set('clsiserverid', clsiServerId)
-    }
-
-    return postJSON(`/project/${projectId}/compile/stop?${params}`, { signal })
-      .catch(error => {
-        setError(error)
-      })
-      .finally(() => {
-        setCompiling(false)
-      })
-  }, [projectId, clsiServerId, signal])
+  // stop a compile manually
+  const stopCompile = useCallback(() => {
+    compiler.stopCompile()
+  }, [compiler])
 
+  // clear the compile cache
   const clearCache = useCallback(() => {
     setClearingCache(true)
 
-    const params = new URLSearchParams()
-
-    if (clsiServerId) {
-      params.set('clsiserverid', clsiServerId)
-    }
-
-    return deleteJSON(`/project/${projectId}/output?${params}`, { signal })
-      .catch(error => {
-        console.error(error)
-        setError('clear-cache')
-      })
-      .finally(() => {
-        setClearingCache(false)
-      })
-  }, [clsiServerId, projectId, setError, signal])
+    return compiler.clearCache().finally(() => {
+      setClearingCache(false)
+    })
+  }, [compiler])
 
   // clear the cache then run a compile, triggered by a menu item
   const recompileFromScratch = useCallback(() => {
-    setClearingCache(true)
     clearCache().then(() => {
-      setClearingCache(false)
-      recompile()
+      compiler.compile()
     })
-  }, [clearCache, recompile])
+  }, [clearCache, compiler])
 
   // switch to either side-by-side or flat (full-width) layout
   const switchLayout = useCallback(() => {
@@ -512,12 +392,9 @@ export default function PdfPreviewProvider({ children }) {
   const value = useMemo(() => {
     return {
       autoCompile,
-      autoCompileLintingError,
+      codeCheckFailed,
       clearCache,
       clearingCache,
-      clsiServerId,
-      compileGroup,
-      compiledOnce,
       compiling,
       draft,
       error,
@@ -529,23 +406,14 @@ export default function PdfPreviewProvider({ children }) {
       pdfLayout,
       pdfUrl,
       rawLog,
-      recompile,
       recompileFromScratch,
       setAutoCompile,
-      setClsiServerId,
-      setCompileGroup,
-      setCompiledOnce,
       setDraft,
-      setError,
-      setHasLintingError, // for story
-      setLogEntries,
-      setPdfDownloadUrl,
-      setPdfLayout,
-      setPdfUrl,
+      setHasLintingError, // only for stories
       setShowLogs,
       setStopOnValidationError,
-      setUiView,
       showLogs,
+      startCompile,
       stopCompile,
       stopOnValidationError,
       switchLayout,
@@ -554,12 +422,9 @@ export default function PdfPreviewProvider({ children }) {
     }
   }, [
     autoCompile,
-    autoCompileLintingError,
+    codeCheckFailed,
     clearCache,
     clearingCache,
-    clsiServerId,
-    compileGroup,
-    compiledOnce,
     compiling,
     draft,
     error,
@@ -571,22 +436,13 @@ export default function PdfPreviewProvider({ children }) {
     pdfLayout,
     pdfUrl,
     rawLog,
-    recompile,
     recompileFromScratch,
     setAutoCompile,
-    setClsiServerId,
-    setCompileGroup,
-    setCompiledOnce,
     setDraft,
-    setError,
-    setHasLintingError,
-    setLogEntries,
-    setPdfDownloadUrl,
-    setPdfLayout,
-    setPdfUrl,
+    setHasLintingError, // only for stories
     setStopOnValidationError,
-    setUiView,
     showLogs,
+    startCompile,
     stopCompile,
     stopOnValidationError,
     switchLayout,
@@ -604,12 +460,9 @@ export default function PdfPreviewProvider({ children }) {
 PdfPreviewContext.Provider.propTypes = {
   value: PropTypes.shape({
     autoCompile: PropTypes.bool.isRequired,
-    autoCompileLintingError: PropTypes.bool.isRequired,
     clearCache: PropTypes.func.isRequired,
     clearingCache: PropTypes.bool.isRequired,
-    clsiServerId: PropTypes.string,
-    compileGroup: PropTypes.string,
-    compiledOnce: PropTypes.bool.isRequired,
+    codeCheckFailed: PropTypes.bool.isRequired,
     compiling: PropTypes.bool.isRequired,
     draft: PropTypes.bool.isRequired,
     error: PropTypes.string,
@@ -621,23 +474,14 @@ PdfPreviewContext.Provider.propTypes = {
     pdfLayout: PropTypes.string,
     pdfUrl: PropTypes.string,
     rawLog: PropTypes.string,
-    recompile: PropTypes.func.isRequired,
     recompileFromScratch: PropTypes.func.isRequired,
     setAutoCompile: PropTypes.func.isRequired,
-    setClsiServerId: PropTypes.func.isRequired,
-    setCompileGroup: PropTypes.func.isRequired,
-    setCompiledOnce: PropTypes.func.isRequired,
     setDraft: PropTypes.func.isRequired,
-    setError: PropTypes.func.isRequired,
     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,
     setStopOnValidationError: PropTypes.func.isRequired,
-    setUiView: PropTypes.func.isRequired,
     showLogs: PropTypes.bool.isRequired,
+    startCompile: PropTypes.func.isRequired,
     stopCompile: PropTypes.func.isRequired,
     stopOnValidationError: PropTypes.bool.isRequired,
     switchLayout: PropTypes.func.isRequired,

+ 179 - 0
services/web/frontend/js/features/pdf-preview/util/compiler.js

@@ -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')
+    })
+  }
+}

+ 1 - 1
services/web/frontend/stories/pdf-js-viewer.stories.js

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

+ 6 - 0
services/web/frontend/stories/pdf-preview.stories.js

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

+ 85 - 89
services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js

@@ -9,7 +9,7 @@ const outputFiles = [
   {
     path: 'output.pdf',
     build: '123',
-    // url: 'about:blank', // TODO: PDF URL to render
+    url: '/build/output.pdf', // TODO: PDF URL to render
     type: 'pdf',
   },
   {
@@ -119,6 +119,18 @@ const storeAndFireEvent = (key, value) => {
   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 () {
   var clock
 
@@ -138,50 +150,31 @@ describe('<PdfPreview/>', function () {
     localStorage.clear()
   })
 
-  it('renders the PDF preview', function () {
-    renderWithEditorContext(<PdfPreview />)
-
-    screen.getByRole('button', { name: 'Recompile' })
-  })
-
-  it('runs a compile only on the first project:joined event', async function () {
+  it('renders the PDF preview', async function () {
     mockCompile()
     mockBuildFile()
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     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 () {
     mockCompile()
     mockBuildFile()
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
 
     // press the Recompile button => compile
     const button = screen.getByRole('button', { name: 'Recompile' })
     button.click()
-    screen.getByRole('button', { name: 'Compiling…' })
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
 
     expect(fetchMock.calls()).to.have.length(6)
@@ -191,12 +184,10 @@ describe('<PdfPreview/>', function () {
     mockCompile()
     mockBuildFile()
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
 
     // switch on auto compile
@@ -206,7 +197,7 @@ describe('<PdfPreview/>', function () {
     fireEvent(window, new CustomEvent('doc:changed'))
     clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
 
-    await screen.findByText('Compiling…')
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
 
     expect(fetchMock.calls()).to.have.length(6)
@@ -216,17 +207,14 @@ describe('<PdfPreview/>', function () {
     mockCompile()
     mockBuildFile()
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
 
     // make sure auto compile is switched off
     storeAndFireEvent('autocompile_enabled:project123', false)
-    screen.getByRole('button', { name: 'Recompile' })
 
     // fire a doc:changed event => no compile
     fireEvent(window, new CustomEvent('doc:changed'))
@@ -242,21 +230,19 @@ describe('<PdfPreview/>', function () {
 
     renderWithEditorContext(<PdfPreview />, {
       scope: {
+        ...scope,
         'settings.syntaxValidation': true, // enable linting in the editor
         hasLintingError: true, // mock a linting error
       },
     })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
 
     // switch on auto compile and syntax checking
     storeAndFireEvent('autocompile_enabled:project123', true)
     storeAndFireEvent('stop_on_validation_error:project123', true)
-    screen.getByRole('button', { name: 'Recompile' })
 
     // fire a doc:changed event => no compile
     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 () {
     mockCompileError('compile-in-progress')
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
+
     screen.getByText(
       '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
   })
 
-  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 () {
     const validationProblems = {
       sizeCheck: {
@@ -327,13 +291,12 @@ describe('<PdfPreview/>', function () {
 
     mockValidationProblems(validationProblems)
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
 
-    // fire a project:joined event => compile
-    screen.getByRole('button', { name: 'Recompile' })
-    fireEvent(window, new CustomEvent('project:joined'))
-    screen.getByRole('button', { name: 'Compiling…' })
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
     await screen.findByRole('button', { name: 'Recompile' })
+
     screen.getByText('Project too large')
     screen.getByText('Unknown main document')
     screen.getByText('Conflicting Paths Found')
@@ -347,36 +310,69 @@ describe('<PdfPreview/>', function () {
     mockBuildFile()
     mockClearCache()
 
-    renderWithEditorContext(<PdfPreview />)
+    renderWithEditorContext(<PdfPreview />, { scope })
+
+    // wait for "compile on load" to finish
+    await screen.findByRole('button', { name: 'Compiling…' })
+    await screen.findByRole('button', { name: 'Recompile' })
 
-    const logsButton = screen.getByRole('button', { name: 'View logs' })
+    const logsButton = screen.getByRole('button', {
+      name: 'This project has an error',
+    })
     logsButton.click()
 
-    let clearCacheButton = screen.getByRole('button', {
+    const clearCacheButton = await screen.findByRole('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…' })
+    // click the button
+    clearCacheButton.click()
     expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+    await waitFor(() => {
+      expect(clearCacheButton.hasAttribute('disabled')).to.be.false
+    })
+
+    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('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()
-    clearCacheButton = await screen.findByRole('button', {
+
+    const clearCacheButton = await screen.findByRole('button', {
       name: 'Clear cached files',
     })
     expect(clearCacheButton.hasAttribute('disabled')).to.be.false
 
-    // click the button
-    clearCacheButton.click()
-    expect(clearCacheButton.hasAttribute('disabled')).to.be.true
-    await waitFor(() => {
-      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
   })
 })