local-compile-context.js 17 KB

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