local-compile-context.js 19 KB

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