local-compile-context.js 18 KB

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