local-compile-context.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react'
  10. import PropTypes from 'prop-types'
  11. import useScopeValue from '../hooks/use-scope-value'
  12. import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
  13. import usePersistedState from '../hooks/use-persisted-state'
  14. import useAbortController from '../hooks/use-abort-controller'
  15. import DocumentCompiler from '../../features/pdf-preview/util/compiler'
  16. import {
  17. send,
  18. sendMBOnce,
  19. sendMBSampled,
  20. } from '../../infrastructure/event-tracking'
  21. import {
  22. buildLogEntryAnnotations,
  23. handleLogFiles,
  24. handleOutputFiles,
  25. } from '../../features/pdf-preview/util/output-files'
  26. import { useIdeContext } from './ide-context'
  27. import { useProjectContext } from './project-context'
  28. import { useEditorContext } from './editor-context'
  29. import { buildFileList } from '../../features/pdf-preview/util/file-list'
  30. export const LocalCompileContext = createContext()
  31. export const CompileContextPropTypes = {
  32. value: PropTypes.shape({
  33. autoCompile: PropTypes.bool.isRequired,
  34. clearingCache: PropTypes.bool.isRequired,
  35. clsiServerId: PropTypes.string,
  36. codeCheckFailed: PropTypes.bool.isRequired,
  37. compiling: PropTypes.bool.isRequired,
  38. deliveryLatencies: PropTypes.object.isRequired,
  39. draft: PropTypes.bool.isRequired,
  40. error: PropTypes.string,
  41. fileList: PropTypes.object,
  42. hasChanges: PropTypes.bool.isRequired,
  43. highlights: PropTypes.arrayOf(PropTypes.object),
  44. logEntries: PropTypes.object,
  45. logEntryAnnotations: PropTypes.object,
  46. pdfDownloadUrl: PropTypes.string,
  47. pdfFile: PropTypes.object,
  48. pdfUrl: PropTypes.string,
  49. pdfViewer: PropTypes.string,
  50. position: PropTypes.object,
  51. rawLog: PropTypes.string,
  52. setAutoCompile: PropTypes.func.isRequired,
  53. setDraft: PropTypes.func.isRequired,
  54. setError: PropTypes.func.isRequired,
  55. setHasLintingError: PropTypes.func.isRequired, // only for storybook
  56. setHighlights: PropTypes.func.isRequired,
  57. setPosition: PropTypes.func.isRequired,
  58. setShowLogs: PropTypes.func.isRequired,
  59. toggleLogs: PropTypes.func.isRequired,
  60. setStopOnFirstError: PropTypes.func.isRequired,
  61. setStopOnValidationError: PropTypes.func.isRequired,
  62. showLogs: PropTypes.bool.isRequired,
  63. showFasterCompilesFeedbackUI: PropTypes.bool.isRequired,
  64. stopOnFirstError: PropTypes.bool.isRequired,
  65. stopOnValidationError: PropTypes.bool.isRequired,
  66. stoppedOnFirstError: PropTypes.bool.isRequired,
  67. uncompiled: PropTypes.bool,
  68. validationIssues: PropTypes.object,
  69. firstRenderDone: PropTypes.func,
  70. cleanupCompileResult: PropTypes.func,
  71. }),
  72. }
  73. LocalCompileContext.Provider.propTypes = CompileContextPropTypes
  74. export function LocalCompileProvider({ children }) {
  75. const ide = useIdeContext()
  76. const { hasPremiumCompile, isProjectOwner } = useEditorContext()
  77. const { _id: projectId, rootDocId } = useProjectContext()
  78. // whether a compile is in progress
  79. const [compiling, setCompiling] = useState(false)
  80. // the log entries parsed from the compile output log
  81. const [logEntries, setLogEntries] = useScopeValueSetterOnly('pdf.logEntries')
  82. // annotations for display in the editor, built from the log entries
  83. const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue(
  84. 'pdf.logEntryAnnotations'
  85. )
  86. // the PDF viewer
  87. const [pdfViewer] = useScopeValue('settings.pdfViewer')
  88. // the URL for downloading the PDF
  89. const [pdfDownloadUrl, setPdfDownloadUrl] =
  90. useScopeValueSetterOnly('pdf.downloadUrl')
  91. // the URL for loading the PDF in the preview pane
  92. const [pdfUrl, setPdfUrl] = useScopeValueSetterOnly('pdf.url')
  93. // low level details for metrics
  94. const [pdfFile, setPdfFile] = useState()
  95. // the project is considered to be "uncompiled" if a doc has changed since the last compile started
  96. const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
  97. // the id of the CLSI server which ran the compile
  98. const [clsiServerId, setClsiServerId] = useState()
  99. // data received in response to a compile request
  100. const [data, setData] = useState()
  101. // callback to be invoked for PdfJsMetrics
  102. const [firstRenderDone, setFirstRenderDone] = useState()
  103. // latencies of compile/pdf download/rendering
  104. const [deliveryLatencies, setDeliveryLatencies] = useState({})
  105. // whether the project has been compiled yet
  106. const [compiledOnce, setCompiledOnce] = useState(false)
  107. // whether the cache is being cleared
  108. const [clearingCache, setClearingCache] = useState(false)
  109. // whether the logs should be visible
  110. const [showLogs, setShowLogs] = useState(false)
  111. // whether the faster compiles feedback UI should be displayed
  112. const [showFasterCompilesFeedbackUI, setShowFasterCompilesFeedbackUI] =
  113. useState(false)
  114. // whether the compile dropdown arrow should be animated
  115. const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
  116. useState(false)
  117. const toggleLogs = useCallback(() => {
  118. setShowLogs(prev => {
  119. if (!prev) {
  120. sendMBOnce('ide-open-logs-once')
  121. }
  122. return !prev
  123. })
  124. }, [setShowLogs])
  125. // an error that occurred
  126. const [error, setError] = useState()
  127. // the list of files that can be downloaded
  128. const [fileList, setFileList] = useState()
  129. // the raw contents of the log file
  130. const [rawLog, setRawLog] = useState()
  131. // validation issues from CLSI
  132. const [validationIssues, setValidationIssues] = useState()
  133. // areas to highlight on the PDF, from synctex
  134. const [highlights, setHighlights] = useState()
  135. // scroll position of the PDF
  136. const [position, setPosition] = usePersistedState(`pdf.position.${projectId}`)
  137. // whether autocompile is switched on
  138. const [autoCompile, setAutoCompile] = usePersistedState(
  139. `autocompile_enabled:${projectId}`,
  140. false,
  141. true
  142. )
  143. // whether the compile should run in draft mode
  144. const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
  145. // whether compiling should stop on first error
  146. const [stopOnFirstError, setStopOnFirstError] = usePersistedState(
  147. `stop_on_first_error:${projectId}`,
  148. false,
  149. true
  150. )
  151. // whether the last compiles stopped on first error
  152. const [stoppedOnFirstError, setStoppedOnFirstError] = useState(false)
  153. // whether compiling should be prevented if there are linting errors
  154. const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
  155. `stop_on_validation_error:${projectId}`,
  156. true,
  157. true
  158. )
  159. // the Document currently open in the editor
  160. const [currentDoc] = useScopeValue('editor.sharejs_doc')
  161. // whether the editor linter found errors
  162. const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
  163. // whether syntax validation is enabled globally
  164. const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
  165. // the timestamp that a doc was last changed or saved
  166. const [changedAt, setChangedAt] = useState(0)
  167. const { signal } = useAbortController()
  168. const cleanupCompileResult = useCallback(() => {
  169. setPdfUrl(null)
  170. setPdfDownloadUrl(null)
  171. setLogEntries(null)
  172. setLogEntryAnnotations({})
  173. }, [setPdfUrl, setPdfDownloadUrl, setLogEntries, setLogEntryAnnotations])
  174. const compilingRef = useRef(false)
  175. useEffect(() => {
  176. compilingRef.current = compiling
  177. }, [compiling])
  178. // the document compiler
  179. const [compiler] = useState(() => {
  180. return new DocumentCompiler({
  181. projectId,
  182. rootDocId,
  183. setChangedAt,
  184. setCompiling,
  185. setData,
  186. setFirstRenderDone,
  187. setDeliveryLatencies,
  188. setError,
  189. cleanupCompileResult,
  190. compilingRef,
  191. signal,
  192. })
  193. })
  194. // keep currentDoc in sync with the compiler
  195. useEffect(() => {
  196. compiler.currentDoc = currentDoc
  197. }, [compiler, currentDoc])
  198. // keep draft setting in sync with the compiler
  199. useEffect(() => {
  200. compiler.setOption('draft', draft)
  201. }, [compiler, draft])
  202. // keep stop on first error setting in sync with the compiler
  203. useEffect(() => {
  204. compiler.setOption('stopOnFirstError', stopOnFirstError)
  205. }, [compiler, stopOnFirstError])
  206. // pass the "uncompiled" value up into the scope for use outside this context provider
  207. useEffect(() => {
  208. setUncompiled(changedAt > 0)
  209. }, [setUncompiled, changedAt])
  210. // always compile the PDF once after opening the project, after the doc has loaded
  211. useEffect(() => {
  212. if (!compiledOnce && currentDoc) {
  213. setCompiledOnce(true)
  214. compiler.compile({ isAutoCompileOnLoad: true })
  215. }
  216. }, [compiledOnce, currentDoc, compiler])
  217. // handle the data returned from a compile request
  218. // note: this should _only_ run when `data` changes,
  219. // the other dependencies must all be static
  220. useEffect(() => {
  221. const abortController = new AbortController()
  222. if (data) {
  223. if (data.clsiServerId) {
  224. setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
  225. }
  226. setShowFasterCompilesFeedbackUI(
  227. Boolean(data.showFasterCompilesFeedbackUI)
  228. )
  229. if (data.outputFiles) {
  230. const outputFiles = new Map()
  231. for (const outputFile of data.outputFiles) {
  232. outputFiles.set(outputFile.path, outputFile)
  233. }
  234. // set the PDF URLs
  235. const result = handleOutputFiles(outputFiles, projectId, data)
  236. if (data.status === 'success') {
  237. setPdfDownloadUrl(result.pdfDownloadUrl)
  238. setPdfFile(result.pdfFile)
  239. setPdfUrl(result.pdfUrl)
  240. }
  241. setFileList(
  242. buildFileList(outputFiles, data.clsiServerId, data.compileGroup)
  243. )
  244. // handle log files
  245. // asynchronous (TODO: cancel on new compile?)
  246. setLogEntryAnnotations(null)
  247. setLogEntries(null)
  248. setRawLog(null)
  249. handleLogFiles(outputFiles, data, abortController.signal).then(
  250. result => {
  251. setRawLog(result.log)
  252. setLogEntries(result.logEntries)
  253. setLogEntryAnnotations(
  254. buildLogEntryAnnotations(
  255. result.logEntries.all,
  256. ide.fileTreeManager
  257. )
  258. )
  259. // sample compile stats for real users
  260. if (
  261. !window.user.alphaProgram &&
  262. ['success', 'stopped-on-first-error'].includes(data.status)
  263. ) {
  264. sendMBSampled(
  265. 'compile-result',
  266. {
  267. errors: result.logEntries.errors.length,
  268. warnings: result.logEntries.warnings.length,
  269. typesetting: result.logEntries.typesetting.length,
  270. newPdfPreview: true, // TODO: is this useful?
  271. stopOnFirstError: data.options.stopOnFirstError,
  272. },
  273. 0.01
  274. )
  275. }
  276. }
  277. )
  278. }
  279. switch (data.status) {
  280. case 'success':
  281. setError(undefined)
  282. setShowLogs(false)
  283. break
  284. case 'stopped-on-first-error':
  285. setError(undefined)
  286. setShowLogs(true)
  287. break
  288. case 'clsi-maintenance':
  289. case 'compile-in-progress':
  290. case 'exited':
  291. case 'failure':
  292. case 'project-too-large':
  293. case 'rate-limited':
  294. case 'terminated':
  295. case 'too-recently-compiled':
  296. setError(data.status)
  297. break
  298. case 'timedout':
  299. setError('timedout')
  300. if (!hasPremiumCompile && isProjectOwner) {
  301. send(
  302. 'subscription-funnel',
  303. 'editor-click-feature',
  304. 'compile-timeout'
  305. )
  306. }
  307. break
  308. case 'autocompile-backoff':
  309. if (!data.options.isAutoCompileOnLoad) {
  310. setError('autocompile-disabled')
  311. setAutoCompile(false)
  312. }
  313. break
  314. case 'unavailable':
  315. setError('clsi-unavailable')
  316. break
  317. case 'validation-problems':
  318. setError('validation-problems')
  319. setValidationIssues(data.validationProblems)
  320. break
  321. default:
  322. setError('error')
  323. break
  324. }
  325. setStoppedOnFirstError(data.status === 'stopped-on-first-error')
  326. }
  327. return () => {
  328. abortController.abort()
  329. }
  330. }, [
  331. data,
  332. ide,
  333. hasPremiumCompile,
  334. isProjectOwner,
  335. projectId,
  336. setAutoCompile,
  337. setClsiServerId,
  338. setLogEntries,
  339. setLogEntryAnnotations,
  340. setPdfDownloadUrl,
  341. setPdfFile,
  342. setPdfUrl,
  343. ])
  344. // switch to logs if there's an error
  345. useEffect(() => {
  346. if (error) {
  347. setShowLogs(true)
  348. }
  349. }, [error])
  350. // whether there has been an autocompile linting error, if syntax validation is switched on
  351. const autoCompileLintingError = Boolean(
  352. autoCompile && syntaxValidation && hasLintingError
  353. )
  354. const codeCheckFailed = stopOnValidationError && autoCompileLintingError
  355. // the project is available for auto-compiling
  356. const canAutoCompile = Boolean(autoCompile && !codeCheckFailed)
  357. // show that the project has pending changes
  358. const hasChanges = Boolean(canAutoCompile && uncompiled && compiledOnce)
  359. // call the debounced autocompile function if the project is available for auto-compiling and it has changed
  360. useEffect(() => {
  361. if (canAutoCompile) {
  362. if (changedAt > 0) {
  363. compiler.debouncedAutoCompile()
  364. }
  365. } else {
  366. compiler.debouncedAutoCompile.cancel()
  367. }
  368. }, [compiler, canAutoCompile, changedAt])
  369. // cancel debounced recompile on unmount
  370. useEffect(() => {
  371. return () => {
  372. compiler.debouncedAutoCompile.cancel()
  373. }
  374. }, [compiler])
  375. // start a compile manually
  376. const startCompile = useCallback(
  377. options => {
  378. compiler.compile(options)
  379. },
  380. [compiler]
  381. )
  382. // stop a compile manually
  383. const stopCompile = useCallback(() => {
  384. compiler.stopCompile()
  385. }, [compiler])
  386. // clear the compile cache
  387. const clearCache = useCallback(() => {
  388. setClearingCache(true)
  389. return compiler
  390. .clearCache()
  391. .then(() => {
  392. setFileList(undefined)
  393. setPdfDownloadUrl(undefined)
  394. setPdfUrl(undefined)
  395. })
  396. .finally(() => {
  397. setClearingCache(false)
  398. })
  399. }, [compiler, setPdfDownloadUrl, setPdfUrl])
  400. // clear the cache then run a compile, triggered by a menu item
  401. const recompileFromScratch = useCallback(() => {
  402. clearCache().then(() => {
  403. compiler.compile()
  404. })
  405. }, [clearCache, compiler])
  406. // After a compile, the compiler sets `data.options` to the options that were
  407. // used for that compile.
  408. const lastCompileOptions = useMemo(() => data?.options || {}, [data])
  409. const value = useMemo(
  410. () => ({
  411. animateCompileDropdownArrow,
  412. autoCompile,
  413. clearCache,
  414. clearingCache,
  415. clsiServerId,
  416. codeCheckFailed,
  417. compiling,
  418. deliveryLatencies,
  419. draft,
  420. error,
  421. fileList,
  422. hasChanges,
  423. highlights,
  424. lastCompileOptions,
  425. logEntryAnnotations,
  426. logEntries,
  427. pdfDownloadUrl,
  428. pdfFile,
  429. pdfUrl,
  430. pdfViewer,
  431. position,
  432. rawLog,
  433. recompileFromScratch,
  434. setAnimateCompileDropdownArrow,
  435. setAutoCompile,
  436. setCompiling,
  437. setDraft,
  438. setError,
  439. setHasLintingError, // only for stories
  440. setHighlights,
  441. setPosition,
  442. setShowLogs,
  443. toggleLogs,
  444. setStopOnFirstError,
  445. setStopOnValidationError,
  446. showLogs,
  447. showFasterCompilesFeedbackUI,
  448. startCompile,
  449. stopCompile,
  450. stopOnFirstError,
  451. stopOnValidationError,
  452. stoppedOnFirstError,
  453. uncompiled,
  454. validationIssues,
  455. firstRenderDone,
  456. setChangedAt,
  457. cleanupCompileResult,
  458. }),
  459. [
  460. animateCompileDropdownArrow,
  461. autoCompile,
  462. clearCache,
  463. clearingCache,
  464. clsiServerId,
  465. codeCheckFailed,
  466. compiling,
  467. deliveryLatencies,
  468. draft,
  469. error,
  470. fileList,
  471. hasChanges,
  472. highlights,
  473. lastCompileOptions,
  474. logEntries,
  475. logEntryAnnotations,
  476. position,
  477. pdfDownloadUrl,
  478. pdfFile,
  479. pdfUrl,
  480. pdfViewer,
  481. rawLog,
  482. recompileFromScratch,
  483. setAnimateCompileDropdownArrow,
  484. setAutoCompile,
  485. setDraft,
  486. setError,
  487. setHasLintingError, // only for stories
  488. setHighlights,
  489. setPosition,
  490. setStopOnFirstError,
  491. setStopOnValidationError,
  492. showLogs,
  493. showFasterCompilesFeedbackUI,
  494. startCompile,
  495. stopCompile,
  496. stopOnFirstError,
  497. stopOnValidationError,
  498. stoppedOnFirstError,
  499. uncompiled,
  500. validationIssues,
  501. firstRenderDone,
  502. setChangedAt,
  503. cleanupCompileResult,
  504. setShowLogs,
  505. toggleLogs,
  506. ]
  507. )
  508. return (
  509. <LocalCompileContext.Provider value={value}>
  510. {children}
  511. </LocalCompileContext.Provider>
  512. )
  513. }
  514. LocalCompileProvider.propTypes = {
  515. children: PropTypes.any,
  516. }
  517. export function useLocalCompileContext(propTypes) {
  518. const data = useContext(LocalCompileContext)
  519. PropTypes.checkPropTypes(
  520. propTypes,
  521. data,
  522. 'data',
  523. 'LocalCompileContext.Provider'
  524. )
  525. return data
  526. }