Просмотр исходного кода

Move project context out of scope value store (#26615)

* Refactor project context to not use scope store

* Fix Cypress tests for project context changes

* Fix frontend React Testing Library tests for project context changes

* Remove redundant code

* Fix some project types in tests

* Remove unused import and fix a type

* Throw an error if updating the project in the project context before joining the project

* Fix some review panel tests

* Remove unused imports

GitOrigin-RevId: 2f0c928b651f387aa980c29aef7d1ba0649790a7
Tim Down 1 год назад
Родитель
Сommit
905cc5d45f
79 измененных файлов с 807 добавлено и 704 удалено
  1. 1 1
      services/web/frontend/js/features/chat/context/chat-context.tsx
  2. 4 8
      services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx
  3. 1 1
      services/web/frontend/js/features/editor-left-menu/components/download-pdf.tsx
  4. 1 1
      services/web/frontend/js/features/editor-left-menu/components/download-source.tsx
  5. 8 10
      services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings-socket-listener.tsx
  6. 2 2
      services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings.tsx
  7. 3 3
      services/web/frontend/js/features/editor-left-menu/hooks/use-root-doc-id.tsx
  8. 6 10
      services/web/frontend/js/features/editor-left-menu/hooks/use-save-project-settings.tsx
  9. 5 8
      services/web/frontend/js/features/editor-left-menu/hooks/use-set-spell-check-language.tsx
  10. 1 1
      services/web/frontend/js/features/editor-left-menu/utils/api.ts
  11. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx
  12. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx
  13. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx
  14. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx
  15. 4 3
      services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx
  16. 1 1
      services/web/frontend/js/features/file-tree/components/file-tree-root.tsx
  17. 1 1
      services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx
  18. 2 1
      services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx
  19. 1 1
      services/web/frontend/js/features/file-view/components/file-view-header.tsx
  20. 1 1
      services/web/frontend/js/features/file-view/components/file-view-image.tsx
  21. 1 1
      services/web/frontend/js/features/file-view/components/file-view-refresh-button.tsx
  22. 1 1
      services/web/frontend/js/features/file-view/components/file-view-text.tsx
  23. 3 6
      services/web/frontend/js/features/history/context/history-context.tsx
  24. 6 4
      services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx
  25. 21 20
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  26. 1 1
      services/web/frontend/js/features/ide-react/context/outline-context.tsx
  27. 3 3
      services/web/frontend/js/features/ide-react/context/permissions-context.tsx
  28. 8 8
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  29. 1 1
      services/web/frontend/js/features/ide-react/context/snapshot-context.tsx
  30. 32 12
      services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts
  31. 2 2
      services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx
  32. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx
  33. 1 1
      services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx
  34. 2 1
      services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts
  35. 4 4
      services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx
  36. 3 3
      services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx
  37. 5 1
      services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx
  38. 1 1
      services/web/frontend/js/features/review-panel-new/context/threads-context.tsx
  39. 8 8
      services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx
  40. 1 1
      services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts
  41. 1 1
      services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts
  42. 9 10
      services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx
  43. 12 9
      services/web/frontend/js/features/share-project-modal/components/edit-member.tsx
  44. 8 2
      services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx
  45. 11 9
      services/web/frontend/js/features/share-project-modal/components/invite.tsx
  46. 4 3
      services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx
  47. 2 2
      services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx
  48. 2 2
      services/web/frontend/js/features/share-project-modal/components/owner-info.tsx
  49. 2 1
      services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx
  50. 17 12
      services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx
  51. 6 15
      services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
  52. 3 3
      services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx
  53. 2 6
      services/web/frontend/js/features/share-project-modal/components/view-member.tsx
  54. 2 1
      services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx
  55. 1 1
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx
  56. 1 1
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx
  57. 1 1
      services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx
  58. 3 7
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  59. 2 1
      services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx
  60. 1 1
      services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx
  61. 30 34
      services/web/frontend/js/shared/context/editor-context.tsx
  62. 3 4
      services/web/frontend/js/shared/context/file-tree-data-context.tsx
  63. 2 7
      services/web/frontend/js/shared/context/local-compile-context.tsx
  64. 59 64
      services/web/frontend/js/shared/context/project-context.tsx
  65. 8 14
      services/web/frontend/js/shared/context/types/project-metadata.tsx
  66. 1 1
      services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts
  67. 48 28
      services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx
  68. 2 1
      services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx
  69. 7 7
      services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx
  70. 28 6
      services/web/test/frontend/features/review-panel/review-panel.spec.tsx
  71. 162 215
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx
  72. 0 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx
  73. 9 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx
  74. 10 3
      services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx
  75. 5 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx
  76. 4 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx
  77. 78 0
      services/web/test/frontend/features/source-editor/helpers/mock-project.ts
  78. 3 58
      services/web/test/frontend/features/source-editor/helpers/mock-scope.ts
  79. 107 34
      services/web/test/frontend/helpers/editor-providers.tsx

+ 1 - 1
services/web/frontend/js/features/chat/context/chat-context.tsx

@@ -200,7 +200,7 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
     clientId.current = chatClientIdGenerator.generate()
     clientId.current = chatClientIdGenerator.generate()
   }
   }
   const user = useUserContext()
   const user = useUserContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext()
   const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext()
   const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext()
   const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext()

+ 4 - 8
services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx

@@ -13,13 +13,9 @@ const EditorCloneProjectModalWrapper = React.memo(
     handleHide: () => void
     handleHide: () => void
     openProject: ({ project_id }: { project_id: string }) => void
     openProject: ({ project_id }: { project_id: string }) => void
   }) {
   }) {
-    const {
-      _id: projectId,
-      name: projectName,
-      tags: projectTags,
-    } = useProjectContext()
+    const { project, tags: projectTags } = useProjectContext()
 
 
-    if (!projectName) {
+    if (!project) {
       // wait for useProjectContext
       // wait for useProjectContext
       return null
       return null
     } else {
     } else {
@@ -28,8 +24,8 @@ const EditorCloneProjectModalWrapper = React.memo(
           handleHide={handleHide}
           handleHide={handleHide}
           show={show}
           show={show}
           handleAfterCloned={openProject}
           handleAfterCloned={openProject}
-          projectId={projectId}
-          projectName={projectName}
+          projectId={project._id}
+          projectName={project.name}
           projectTags={projectTags}
           projectTags={projectTags}
         />
         />
       )
       )

+ 1 - 1
services/web/frontend/js/features/editor-left-menu/components/download-pdf.tsx

@@ -9,7 +9,7 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 export default function DownloadPDF() {
 export default function DownloadPDF() {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { pdfDownloadUrl, pdfUrl } = useCompileContext()
   const { pdfDownloadUrl, pdfUrl } = useCompileContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   function sendDownloadEvent() {
   function sendDownloadEvent() {
     eventTracking.sendMB('download-pdf-button-click', {
     eventTracking.sendMB('download-pdf-button-click', {

+ 1 - 1
services/web/frontend/js/features/editor-left-menu/components/download-source.tsx

@@ -6,7 +6,7 @@ import MaterialIcon from '@/shared/components/material-icon'
 
 
 export default function DownloadSource() {
 export default function DownloadSource() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   function sendDownloadEvent() {
   function sendDownloadEvent() {
     eventTracking.sendMB('download-zip-button-click', {
     eventTracking.sendMB('download-zip-button-click', {

+ 8 - 10
services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings-socket-listener.tsx

@@ -1,40 +1,38 @@
 import { useCallback, useEffect } from 'react'
 import { useCallback, useEffect } from 'react'
 import { useIdeContext } from '../../../shared/context/ide-context'
 import { useIdeContext } from '../../../shared/context/ide-context'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 import type { ProjectSettings } from '../utils/api'
 import type { ProjectSettings } from '../utils/api'
 
 
 export default function useProjectWideSettingsSocketListener() {
 export default function useProjectWideSettingsSocketListener() {
   const { socket } = useIdeContext()
   const { socket } = useIdeContext()
 
 
-  const [project, setProject] = useScopeValue<ProjectSettings | undefined>(
-    'project'
-  )
+  const { project, updateProject } = useProjectContext()
 
 
   const setCompiler = useCallback(
   const setCompiler = useCallback(
     (compiler: ProjectSettings['compiler']) => {
     (compiler: ProjectSettings['compiler']) => {
       if (project) {
       if (project) {
-        setProject({ ...project, compiler })
+        updateProject({ compiler })
       }
       }
     },
     },
-    [project, setProject]
+    [project, updateProject]
   )
   )
 
 
   const setImageName = useCallback(
   const setImageName = useCallback(
     (imageName: ProjectSettings['imageName']) => {
     (imageName: ProjectSettings['imageName']) => {
       if (project) {
       if (project) {
-        setProject({ ...project, imageName })
+        updateProject({ imageName })
       }
       }
     },
     },
-    [project, setProject]
+    [project, updateProject]
   )
   )
 
 
   const setSpellCheckLanguage = useCallback(
   const setSpellCheckLanguage = useCallback(
     (spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
     (spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
       if (project) {
       if (project) {
-        setProject({ ...project, spellCheckLanguage })
+        updateProject({ spellCheckLanguage })
       }
       }
     },
     },
-    [project, setProject]
+    [project, updateProject]
   )
   )
 
 
   useEffect(() => {
   useEffect(() => {

+ 2 - 2
services/web/frontend/js/features/editor-left-menu/hooks/use-project-wide-settings.tsx

@@ -1,14 +1,14 @@
 import { useCallback } from 'react'
 import { useCallback } from 'react'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
 import type { ProjectSettings } from '../utils/api'
 import type { ProjectSettings } from '../utils/api'
 import useRootDocId from './use-root-doc-id'
 import useRootDocId from './use-root-doc-id'
 import useSaveProjectSettings from './use-save-project-settings'
 import useSaveProjectSettings from './use-save-project-settings'
 import useSetSpellCheckLanguage from './use-set-spell-check-language'
 import useSetSpellCheckLanguage from './use-set-spell-check-language'
 import { debugConsole } from '@/utils/debugging'
 import { debugConsole } from '@/utils/debugging'
+import { useProjectContext } from '@/shared/context/project-context'
 
 
 export default function useProjectWideSettings() {
 export default function useProjectWideSettings() {
   // The value will be undefined on mount
   // The value will be undefined on mount
-  const [project] = useScopeValue<ProjectSettings | undefined>('project')
+  const { project } = useProjectContext()
   const saveProjectSettings = useSaveProjectSettings()
   const saveProjectSettings = useSaveProjectSettings()
 
 
   const setCompiler = useCallback(
   const setCompiler = useCallback(

+ 3 - 3
services/web/frontend/js/features/editor-left-menu/hooks/use-root-doc-id.tsx

@@ -1,12 +1,12 @@
 import { useCallback } from 'react'
 import { useCallback } from 'react'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 import type { ProjectSettings } from '../utils/api'
 import type { ProjectSettings } from '../utils/api'
 import useSaveProjectSettings from './use-save-project-settings'
 import useSaveProjectSettings from './use-save-project-settings'
 
 
 export default function useRootDocId() {
 export default function useRootDocId() {
-  const [rootDocId] =
-    useScopeValue<ProjectSettings['rootDocId']>('project.rootDocId')
+  const { project } = useProjectContext()
+  const rootDocId = project?.rootDocId
   const { permissionsLevel } = useIdeReactContext()
   const { permissionsLevel } = useIdeReactContext()
   const saveProjectSettings = useSaveProjectSettings()
   const saveProjectSettings = useSaveProjectSettings()
 
 

+ 6 - 10
services/web/frontend/js/features/editor-left-menu/hooks/use-save-project-settings.tsx

@@ -1,25 +1,21 @@
 import { type ProjectSettings, saveProjectSettings } from '../utils/api'
 import { type ProjectSettings, saveProjectSettings } from '../utils/api'
-import { useProjectContext } from '../../../shared/context/project-context'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 
 
 export default function useSaveProjectSettings() {
 export default function useSaveProjectSettings() {
-  // projectSettings value will be undefined on mount
-  const [projectSettings, setProjectSettings] = useScopeValue<
-    ProjectSettings | undefined
-  >('project')
-  const { _id: projectId } = useProjectContext()
+  const { projectId, project, updateProject } = useProjectContext()
 
 
   return async (
   return async (
     key: keyof ProjectSettings,
     key: keyof ProjectSettings,
     newSetting: ProjectSettings[keyof ProjectSettings]
     newSetting: ProjectSettings[keyof ProjectSettings]
   ) => {
   ) => {
-    if (projectSettings) {
-      const currentSetting = projectSettings[key]
+    if (project) {
+      const currentSetting = project[key]
       if (currentSetting !== newSetting) {
       if (currentSetting !== newSetting) {
         await saveProjectSettings(projectId, {
         await saveProjectSettings(projectId, {
           [key]: newSetting,
           [key]: newSetting,
         })
         })
-        setProjectSettings({ ...projectSettings, [key]: newSetting })
+
+        updateProject({ [key]: newSetting })
       }
       }
     }
     }
   }
   }

+ 5 - 8
services/web/frontend/js/features/editor-left-menu/hooks/use-set-spell-check-language.tsx

@@ -1,12 +1,11 @@
 import { useCallback } from 'react'
 import { useCallback } from 'react'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 import { type ProjectSettings, saveUserSettings } from '../utils/api'
 import { type ProjectSettings, saveUserSettings } from '../utils/api'
 import useSaveProjectSettings from './use-save-project-settings'
 import useSaveProjectSettings from './use-save-project-settings'
 
 
 export default function useSetSpellCheckLanguage() {
 export default function useSetSpellCheckLanguage() {
-  const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue<
-    ProjectSettings['spellCheckLanguage']
-  >('project.spellCheckLanguage')
+  const { project } = useProjectContext()
+  const spellCheckLanguage = project?.spellCheckLanguage
   const saveProjectSettings = useSaveProjectSettings()
   const saveProjectSettings = useSaveProjectSettings()
 
 
   return useCallback(
   return useCallback(
@@ -16,10 +15,8 @@ export default function useSetSpellCheckLanguage() {
         newSpellCheckLanguage !== spellCheckLanguage
         newSpellCheckLanguage !== spellCheckLanguage
 
 
       if (allowUpdate) {
       if (allowUpdate) {
-        setSpellCheckLanguage(newSpellCheckLanguage)
-
         // Save project settings is created from hooks because it will save the value on
         // Save project settings is created from hooks because it will save the value on
-        // both server-side and client-side (angular scope)
+        // both server-side and client-side (project context)
         saveProjectSettings('spellCheckLanguage', newSpellCheckLanguage)
         saveProjectSettings('spellCheckLanguage', newSpellCheckLanguage)
 
 
         // For user settings, we only need to save it on server-side,
         // For user settings, we only need to save it on server-side,
@@ -27,6 +24,6 @@ export default function useSetSpellCheckLanguage() {
         saveUserSettings('spellCheckLanguage', newSpellCheckLanguage)
         saveUserSettings('spellCheckLanguage', newSpellCheckLanguage)
       }
       }
     },
     },
-    [setSpellCheckLanguage, spellCheckLanguage, saveProjectSettings]
+    [spellCheckLanguage, saveProjectSettings]
   )
   )
 }
 }

+ 1 - 1
services/web/frontend/js/features/editor-left-menu/utils/api.ts

@@ -4,7 +4,7 @@ import { postJSON } from '../../../infrastructure/fetch-json'
 import { debugConsole } from '@/utils/debugging'
 import { debugConsole } from '@/utils/debugging'
 import { UserSettings } from '../../../../../types/user-settings'
 import { UserSettings } from '../../../../../types/user-settings'
 
 
-export type ProjectSettings = {
+export interface ProjectSettings {
   compiler: ProjectCompiler
   compiler: ProjectCompiler
   imageName: string
   imageName: string
   rootDocId: string
   rootDocId: string

+ 1 - 1
services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-upload-conflicts.tsx

@@ -96,7 +96,7 @@ export function FolderUploadConflicts({
   setError: (error: string) => void
   setError: (error: string) => void
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   // Don't allow overwriting files with a folder
   // Don't allow overwriting files with a folder
   const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')
   const hasFileConflict = conflicts.some(conflict => conflict.type === 'file')

+ 1 - 1
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx

@@ -191,7 +191,7 @@ function SelectProject({
   setSelectedProject,
   setSelectedProject,
 }: SelectProjectProps) {
 }: SelectProjectProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const { data, error, loading } = useUserProjects()
   const { data, error, loading } = useUserProjects()
 
 

+ 1 - 1
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx

@@ -28,7 +28,7 @@ export default function FileTreeUploadDoc() {
   const { parentFolderId, cancel, droppedFiles, setDroppedFiles } =
   const { parentFolderId, cancel, droppedFiles, setDroppedFiles } =
     useFileTreeActionable()
     useFileTreeActionable()
   const { fileTreeData } = useFileTreeData()
   const { fileTreeData } = useFileTreeData()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const [error, setError] = useState<string>()
   const [error, setError] = useState<string>()
 
 

+ 1 - 1
services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.tsx

@@ -6,7 +6,7 @@ import { useLocation } from '../../../../shared/hooks/use-location'
 // handle "not-logged-in" errors by redirecting to the login page
 // handle "not-logged-in" errors by redirecting to the login page
 export default function RedirectToLogin() {
 export default function RedirectToLogin() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
   const location = useLocation()
   const location = useLocation()
 
 

+ 4 - 3
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx

@@ -25,14 +25,15 @@ function FileTreeItemMenuItems() {
     selectedFileName,
     selectedFileName,
   } = useFileTreeActionable()
   } = useFileTreeActionable()
 
 
-  const { owner } = useProjectContext()
+  const { project } = useProjectContext()
+  const projectOwner = project?.owner?._id
 
 
   const downloadWithAnalytics = useCallback(() => {
   const downloadWithAnalytics = useCallback(() => {
     // we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
     // we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
     if (selectedFileName?.endsWith('.bib')) {
     if (selectedFileName?.endsWith('.bib')) {
-      eventTracking.sendMB('download-bib-file', { projectOwner: owner._id })
+      eventTracking.sendMB('download-bib-file', { projectOwner })
     }
     }
-  }, [selectedFileName, owner])
+  }, [selectedFileName, projectOwner])
 
 
   const createWithAnalytics = useCallback(() => {
   const createWithAnalytics = useCallback(() => {
     eventTracking.sendMB('new-file-click', { location: 'file-menu' })
     eventTracking.sendMB('new-file-click', { location: 'file-menu' })

+ 1 - 1
services/web/frontend/js/features/file-tree/components/file-tree-root.tsx

@@ -40,7 +40,7 @@ const FileTreeRoot = React.memo<{
 }) {
 }) {
   const [fileTreeContainer, setFileTreeContainer] =
   const [fileTreeContainer, setFileTreeContainer] =
     useState<HTMLDivElement | null>(null)
     useState<HTMLDivElement | null>(null)
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { fileTreeData } = useFileTreeData()
   const { fileTreeData } = useFileTreeData()
   const isReady = Boolean(projectId && fileTreeData)
   const isReady = Boolean(projectId && fileTreeData)
   const newEditor = useIsNewEditorEnabled()
   const newEditor = useIsNewEditorEnabled()

+ 1 - 1
services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx

@@ -222,7 +222,7 @@ function fileTreeActionableReducer(state: State, action: Action) {
 export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
 export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
   children,
   children,
 }) => {
 }) => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { fileTreeReadOnly } = useFileTreeData()
   const { fileTreeReadOnly } = useFileTreeData()
   const { indexAllReferences } = useReferencesContext()
   const { indexAllReferences } = useReferencesContext()
   const { write } = usePermissionsContext()
   const { write } = usePermissionsContext()

+ 2 - 1
services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx

@@ -128,7 +128,8 @@ export const FileTreeSelectableProvider: FC<
     onSelect: (value: FindResult[]) => void
     onSelect: (value: FindResult[]) => void
   }>
   }>
 > = ({ onSelect, children }) => {
 > = ({ onSelect, children }) => {
-  const { _id: projectId, rootDocId } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const rootDocId = project?.rootDocId
 
 
   const [initialSelectedEntityId] = usePersistedState(
   const [initialSelectedEntityId] = usePersistedState(
     `doc.open_id.${projectId}`,
     `doc.open_id.${projectId}`,

+ 1 - 1
services/web/frontend/js/features/file-view/components/file-view-header.tsx

@@ -49,7 +49,7 @@ type FileViewHeaderProps = {
 }
 }
 
 
 export default function FileViewHeader({ file }: FileViewHeaderProps) {
 export default function FileViewHeader({ file }: FileViewHeaderProps) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { fileTreeReadOnly } = useFileTreeData()
   const { fileTreeReadOnly } = useFileTreeData()
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 

+ 1 - 1
services/web/frontend/js/features/file-view/components/file-view-image.tsx

@@ -11,7 +11,7 @@ export default function FileViewImage({
   onLoad: () => void
   onLoad: () => void
   onError: () => void
   onError: () => void
 }) {
 }) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   return (
   return (
     <img
     <img
       src={fileUrl(projectId, file.id, file.hash)}
       src={fileUrl(projectId, file.id, file.hash)}

+ 1 - 1
services/web/frontend/js/features/file-view/components/file-view-refresh-button.tsx

@@ -31,7 +31,7 @@ export default function FileViewRefreshButton({
   setRefreshError,
   setRefreshError,
   file,
   file,
 }: FileViewRefreshButtonProps) {
 }: FileViewRefreshButtonProps) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [refreshing, setRefreshing] = useState(false)
   const [refreshing, setRefreshing] = useState(false)
   const isMountedRef = useIsMounted()
   const isMountedRef = useIsMounted()
 
 

+ 1 - 1
services/web/frontend/js/features/file-view/components/file-view-text.tsx

@@ -16,7 +16,7 @@ export default function FileViewText({
   onLoad: () => void
   onLoad: () => void
   onError: () => void
   onError: () => void
 }) {
 }) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const [textPreview, setTextPreview] = useState('')
   const [textPreview, setTextPreview] = useState('')
   const [shouldShowDots, setShouldShowDots] = useState(false)
   const [shouldShowDots, setShouldShowDots] = useState(false)

+ 3 - 6
services/web/frontend/js/features/history/context/history-context.tsx

@@ -78,13 +78,10 @@ const updatesInfoInitialState: HistoryContextValue['updatesInfo'] = {
 function useHistory() {
 function useHistory() {
   const { view } = useLayoutContext()
   const { view } = useLayoutContext()
   const user = useUserContext()
   const user = useUserContext()
-  const project = useProjectContext()
+  const { projectId, project, features } = useProjectContext()
   const userId = user.id
   const userId = user.id
-  const projectId = project._id
-  const projectOwnerId = project.owner?._id
-  const userHasFullFeature = Boolean(
-    project.features?.versioning || user.isAdmin
-  )
+  const projectOwnerId = project?.owner?._id
+  const userHasFullFeature = Boolean(features.versioning || user.isAdmin)
   const currentUserIsOwner = projectOwnerId === userId
   const currentUserIsOwner = projectOwnerId === userId
 
 
   const [selection, setSelection] = useState<Selection>(selectionInitialState)
   const [selection, setSelection] = useState<Selection>(selectionInitialState)

+ 6 - 4
services/web/frontend/js/features/ide-react/context/file-tree-open-context.tsx

@@ -39,7 +39,9 @@ const FileTreeOpenContext = createContext<
 export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
 export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
   children,
   children,
 }) => {
 }) => {
-  const { rootDocId, owner } = useProjectContext()
+  const { project } = useProjectContext()
+  const rootDocId = project?.rootDocId
+  const projectOwner = project?.owner?._id
   const { eventEmitter, projectJoined } = useIdeReactContext()
   const { eventEmitter, projectJoined } = useIdeReactContext()
   const { openDocWithId, openInitialDoc } = useEditorManagerContext()
   const { openDocWithId, openInitialDoc } = useEditorManagerContext()
   const { currentDocumentId } = useEditorOpenDocContext()
   const { currentDocumentId } = useEditorOpenDocContext()
@@ -81,7 +83,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
         openDocWithId(selected.entity._id, { keepCurrentView: true })
         openDocWithId(selected.entity._id, { keepCurrentView: true })
         if (selected.entity.name.endsWith('.bib')) {
         if (selected.entity.name.endsWith('.bib')) {
           sendMB('open-bib-file', {
           sendMB('open-bib-file', {
-            projectOwner: owner._id,
+            projectOwner,
             isSampleFile: selected.entity.name === 'sample.bib',
             isSampleFile: selected.entity.name === 'sample.bib',
             linkedFileProvider: null,
             linkedFileProvider: null,
           })
           })
@@ -96,7 +98,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
       if (openFile) {
       if (openFile) {
         if (selected?.entity?.name?.endsWith('.bib')) {
         if (selected?.entity?.name?.endsWith('.bib')) {
           sendMB('open-bib-file', {
           sendMB('open-bib-file', {
-            projectOwner: owner._id,
+            projectOwner,
             isSampleFile: false,
             isSampleFile: false,
             linkedFileProvider: (selected.entity as FileRef).linkedFileData
             linkedFileProvider: (selected.entity as FileRef).linkedFileData
               ?.provider,
               ?.provider,
@@ -105,7 +107,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
         window.dispatchEvent(new CustomEvent('file-view:file-opened'))
         window.dispatchEvent(new CustomEvent('file-view:file-opened'))
       }
       }
     },
     },
-    [fileTreeReady, setOpenFile, openDocWithId, owner]
+    [fileTreeReady, setOpenFile, openDocWithId, projectOwner]
   )
   )
 
 
   const handleFileTreeDelete = useCallback(
   const handleFileTreeDelete = useCallback(

+ 21 - 20
services/web/frontend/js/features/ide-react/context/ide-react-context.tsx

@@ -19,6 +19,8 @@ import { postJSON } from '@/infrastructure/fetch-json'
 import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
 import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
 import { type PermissionsLevel } from '@/features/ide-react/types/permissions'
 import { type PermissionsLevel } from '@/features/ide-react/types/permissions'
+import { useProjectContext } from '@/shared/context/project-context'
+import { ProjectMetadata } from '@/shared/context/types/project-metadata'
 
 
 const LOADED_AT = new Date()
 const LOADED_AT = new Date()
 
 
@@ -44,10 +46,6 @@ function populateIdeReactScope(store: ReactScopeValueStore) {
   store.set('settings', {})
   store.set('settings', {})
 }
 }
 
 
-function populateProjectScope(store: ReactScopeValueStore) {
-  store.allowNonExistentPath('project', true)
-}
-
 function populatePdfScope(store: ReactScopeValueStore) {
 function populatePdfScope(store: ReactScopeValueStore) {
   store.allowNonExistentPath('pdf', true)
   store.allowNonExistentPath('pdf', true)
 }
 }
@@ -61,7 +59,6 @@ export function createReactScopeValueStore() {
   // initialization code together with the context and would only populate
   // initialization code together with the context and would only populate
   // necessary values in the store, but this is simpler for now
   // necessary values in the store, but this is simpler for now
   populateIdeReactScope(scopeStore)
   populateIdeReactScope(scopeStore)
-  populateProjectScope(scopeStore)
   populatePdfScope(scopeStore)
   populatePdfScope(scopeStore)
 
 
   scopeStore.allowNonExistentPath('hasLintingError')
   scopeStore.allowNonExistentPath('hasLintingError')
@@ -94,6 +91,8 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const [projectJoined, setProjectJoined] = useState(false)
   const [projectJoined, setProjectJoined] = useState(false)
 
 
   const { socket, getSocketDebuggingInfo } = useConnectionContext()
   const { socket, getSocketDebuggingInfo } = useConnectionContext()
+  const { joinProject, project } = useProjectContext()
+  const spellCheckLanguage = project?.spellCheckLanguage
 
 
   const reportError = useCallback(
   const reportError = useCallback(
     (error: any, meta?: Record<string, any>) => {
     (error: any, meta?: Record<string, any>) => {
@@ -105,7 +104,7 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
         performance_now: performance.now(),
         performance_now: performance.now(),
         release,
         release,
         client_load: LOADED_AT,
         client_load: LOADED_AT,
-        spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'),
+        spellCheckLanguage,
         ...getSocketDebuggingInfo(),
         ...getSocketDebuggingInfo(),
       }
       }
 
 
@@ -124,17 +123,28 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
         },
         },
       })
       })
     },
     },
-    [release, projectId, getSocketDebuggingInfo, scopeStore]
+    [release, projectId, getSocketDebuggingInfo, spellCheckLanguage]
   )
   )
 
 
   // Populate scope values when joining project, then fire project:joined event
   // Populate scope values when joining project, then fire project:joined event
   useEffect(() => {
   useEffect(() => {
     function handleJoinProjectResponse({
     function handleJoinProjectResponse({
-      project: { rootDoc_id: rootDocId, ..._project },
+      project: {
+        rootDoc_id: rootDocId,
+        publicAccesLevel: publicAccessLevel,
+        ..._project
+      },
       permissionsLevel,
       permissionsLevel,
     }: JoinProjectPayload) {
     }: JoinProjectPayload) {
-      const project = { ..._project, rootDocId }
-      scopeStore.set('project', project)
+      const project = { ..._project, rootDocId, publicAccessLevel }
+
+      // Cast the project from the payload as ProjectMetadata to ensure it has
+      // the correct type for the context. It must be close enough because the
+      // data structure hasn't changed and it worked previously. This type
+      // coercion was previously sidestepped by adding the project to the scope
+      // store, which does not enforce types.
+      joinProject(project as unknown as ProjectMetadata)
+
       setPermissionsLevel(permissionsLevel)
       setPermissionsLevel(permissionsLevel)
       // Make watchers update immediately
       // Make watchers update immediately
       scopeStore.flushUpdates()
       scopeStore.flushUpdates()
@@ -142,21 +152,12 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
       setProjectJoined(true)
       setProjectJoined(true)
     }
     }
 
 
-    function handleMainBibliographyDocUpdated(payload: string) {
-      scopeStore.set('project.mainBibliographyDoc_id', payload)
-    }
-
     socket.on('joinProjectResponse', handleJoinProjectResponse)
     socket.on('joinProjectResponse', handleJoinProjectResponse)
-    socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated)
 
 
     return () => {
     return () => {
       socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
       socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
-      socket.removeListener(
-        'mainBibliographyDocUpdated',
-        handleMainBibliographyDocUpdated
-      )
     }
     }
-  }, [socket, eventEmitter, scopeStore])
+  }, [socket, eventEmitter, scopeStore, joinProject])
 
 
   const ide = useMemo(() => {
   const ide = useMemo(() => {
     return {
     return {

+ 1 - 1
services/web/frontend/js/features/ide-react/context/outline-context.tsx

@@ -124,7 +124,7 @@ export const OutlineProvider: FC<React.PropsWithChildren> = ({ children }) => {
     [openDocName]
     [openDocName]
   )
   )
 
 
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const storageKey = `file_outline.expanded.${projectId}`
   const storageKey = `file_outline.expanded.${projectId}`
 
 
   const [outlineExpanded, setOutlineExpanded] = useState(
   const [outlineExpanded, setOutlineExpanded] = useState(

+ 3 - 3
services/web/frontend/js/features/ide-react/context/permissions-context.tsx

@@ -98,7 +98,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
   const { permissionsLevel } = useIdeReactContext()
   const { permissionsLevel } = useIdeReactContext()
   const hasViewerPermissions = useViewerPermissions()
   const hasViewerPermissions = useViewerPermissions()
   const anonymous = getMeta('ol-anonymous')
   const anonymous = getMeta('ol-anonymous')
-  const project = useProjectContext()
+  const { features } = useProjectContext()
 
 
   useEffect(() => {
   useEffect(() => {
     let activePermissionsMap
     let activePermissionsMap
@@ -106,7 +106,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
       activePermissionsMap = linkSharingWarningPermissionsMap
       activePermissionsMap = linkSharingWarningPermissionsMap
     } else if (anonymous) {
     } else if (anonymous) {
       activePermissionsMap = anonymousPermissionsMap
       activePermissionsMap = anonymousPermissionsMap
-    } else if (!project.features.trackChanges) {
+    } else if (!features.trackChanges) {
       activePermissionsMap = noTrackChangesPermissionsMap
       activePermissionsMap = noTrackChangesPermissionsMap
     } else {
     } else {
       activePermissionsMap = permissionsMap
       activePermissionsMap = permissionsMap
@@ -117,7 +117,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
     permissionsLevel,
     permissionsLevel,
     setPermissions,
     setPermissions,
     hasViewerPermissions,
     hasViewerPermissions,
-    project.features.trackChanges,
+    features.trackChanges,
   ])
   ])
 
 
   useEffect(() => {
   useEffect(() => {

+ 8 - 8
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -75,10 +75,10 @@ export const ReactContextRoot: FC<
     <Providers.SplitTestProvider>
     <Providers.SplitTestProvider>
       <Providers.ModalsContextProvider>
       <Providers.ModalsContextProvider>
         <Providers.ConnectionProvider>
         <Providers.ConnectionProvider>
-          <Providers.IdeReactProvider>
-            <Providers.UserProvider>
-              <Providers.UserSettingsProvider>
-                <Providers.ProjectProvider>
+          <Providers.ProjectProvider>
+            <Providers.IdeReactProvider>
+              <Providers.UserProvider>
+                <Providers.UserSettingsProvider>
                   <Providers.SnapshotProvider>
                   <Providers.SnapshotProvider>
                     <Providers.DetachProvider>
                     <Providers.DetachProvider>
                       <Providers.EditorPropertiesProvider>
                       <Providers.EditorPropertiesProvider>
@@ -128,10 +128,10 @@ export const ReactContextRoot: FC<
                       </Providers.EditorPropertiesProvider>
                       </Providers.EditorPropertiesProvider>
                     </Providers.DetachProvider>
                     </Providers.DetachProvider>
                   </Providers.SnapshotProvider>
                   </Providers.SnapshotProvider>
-                </Providers.ProjectProvider>
-              </Providers.UserSettingsProvider>
-            </Providers.UserProvider>
-          </Providers.IdeReactProvider>
+                </Providers.UserSettingsProvider>
+              </Providers.UserProvider>
+            </Providers.IdeReactProvider>
+          </Providers.ProjectProvider>
         </Providers.ConnectionProvider>
         </Providers.ConnectionProvider>
       </Providers.ModalsContextProvider>
       </Providers.ModalsContextProvider>
     </Providers.SplitTestProvider>
     </Providers.SplitTestProvider>

+ 1 - 1
services/web/frontend/js/features/ide-react/context/snapshot-context.tsx

@@ -55,7 +55,7 @@ export const SnapshotContext = createContext<
 >(undefined)
 >(undefined)
 
 
 export const SnapshotProvider: FC<React.PropsWithChildren> = ({ children }) => {
 export const SnapshotProvider: FC<React.PropsWithChildren> = ({ children }) => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [snapshotLoadingState, setSnapshotLoadingState] =
   const [snapshotLoadingState, setSnapshotLoadingState] =
     useState<SnapshotLoadingState>('')
     useState<SnapshotLoadingState>('')
   const [snapshotUpdater] = useState(() => new SnapshotUpdater(projectId))
   const [snapshotUpdater] = useState(() => new SnapshotUpdater(projectId))

+ 32 - 12
services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts

@@ -4,7 +4,7 @@ import {
   listProjectInvites,
   listProjectInvites,
   listProjectMembers,
   listProjectMembers,
 } from '@/features/share-project-modal/utils/api'
 } from '@/features/share-project-modal/utils/api'
-import useScopeValue from '@/shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useModalsContext } from '@/features/ide-react/context/modals-context'
 import { useModalsContext } from '@/features/ide-react/context/modals-context'
@@ -16,11 +16,9 @@ import { useLocation } from '@/shared/hooks/use-location'
 function useSocketListeners() {
 function useSocketListeners() {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { socket } = useConnectionContext()
   const { socket } = useConnectionContext()
-  const { permissionsLevel, projectId } = useIdeReactContext()
   const { showGenericMessageModal } = useModalsContext()
   const { showGenericMessageModal } = useModalsContext()
-  const [, setPublicAccessLevel] = useScopeValue('project.publicAccesLevel')
-  const [, setProjectMembers] = useScopeValue('project.members')
-  const [, setProjectInvites] = useScopeValue('project.invites')
+  const { permissionsLevel } = useIdeReactContext()
+  const { projectId, updateProject } = useProjectContext()
   const location = useLocation()
   const location = useLocation()
 
 
   useSocketListener(
   useSocketListener(
@@ -51,10 +49,10 @@ function useSocketListeners() {
     useCallback(
     useCallback(
       (data: { newAccessLevel?: PublicAccessLevel }) => {
       (data: { newAccessLevel?: PublicAccessLevel }) => {
         if (data.newAccessLevel) {
         if (data.newAccessLevel) {
-          setPublicAccessLevel(data.newAccessLevel)
+          updateProject({ publicAccessLevel: data.newAccessLevel })
         }
         }
       },
       },
-      [setPublicAccessLevel]
+      [updateProject]
     )
     )
   )
   )
 
 
@@ -65,13 +63,13 @@ function useSocketListeners() {
       listProjectMembers(projectId)
       listProjectMembers(projectId)
         .then(({ members }) => {
         .then(({ members }) => {
           if (members) {
           if (members) {
-            setProjectMembers(members)
+            updateProject({ members })
           }
           }
         })
         })
         .catch(err => {
         .catch(err => {
           debugConsole.error('Error fetching members for project', err)
           debugConsole.error('Error fetching members for project', err)
         })
         })
-    }, [projectId, setProjectMembers])
+    }, [projectId, updateProject])
   )
   )
 
 
   useSocketListener(
   useSocketListener(
@@ -83,7 +81,7 @@ function useSocketListeners() {
           listProjectMembers(projectId)
           listProjectMembers(projectId)
             .then(({ members }) => {
             .then(({ members }) => {
               if (members) {
               if (members) {
-                setProjectMembers(members)
+                updateProject({ members })
               }
               }
             })
             })
             .catch(err => {
             .catch(err => {
@@ -95,7 +93,7 @@ function useSocketListeners() {
           listProjectInvites(projectId)
           listProjectInvites(projectId)
             .then(({ invites }) => {
             .then(({ invites }) => {
               if (invites) {
               if (invites) {
-                setProjectInvites(invites)
+                updateProject({ invites })
               }
               }
             })
             })
             .catch(err => {
             .catch(err => {
@@ -103,7 +101,29 @@ function useSocketListeners() {
             })
             })
         }
         }
       },
       },
-      [projectId, setProjectInvites, setProjectMembers, permissionsLevel]
+      [projectId, updateProject, permissionsLevel]
+    )
+  )
+
+  useSocketListener(
+    socket,
+    'mainBibliographyDocUpdated',
+    useCallback(
+      (payload: string) => {
+        updateProject({ mainBibliographyDocId: payload })
+      },
+      [updateProject]
+    )
+  )
+
+  useSocketListener(
+    socket,
+    'projectNameUpdated',
+    useCallback(
+      (payload: string) => {
+        updateProject({ name: payload })
+      },
+      [updateProject]
     )
     )
   )
   )
 }
 }

+ 2 - 2
services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx

@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
 
 
 export const DownloadProjectZip = () => {
 export const DownloadProjectZip = () => {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const sendDownloadEvent = useCallback(() => {
   const sendDownloadEvent = useCallback(() => {
     sendMB('download-zip-button-click', {
     sendMB('download-zip-button-click', {
       projectId,
       projectId,
@@ -44,7 +44,7 @@ export const DownloadProjectZip = () => {
 export const DownloadProjectPDF = () => {
 export const DownloadProjectPDF = () => {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { pdfDownloadUrl, pdfUrl } = useCompileContext()
   const { pdfDownloadUrl, pdfUrl } = useCompileContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const sendDownloadEvent = useCallback(() => {
   const sendDownloadEvent = useCallback(() => {
     sendMB('download-pdf-button-click', {
     sendMB('download-pdf-button-click', {
       projectId,
       projectId,

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx

@@ -9,7 +9,7 @@ import MaterialIcon from '@/shared/components/material-icon'
 function PdfHybridDownloadButton() {
 function PdfHybridDownloadButton() {
   const { pdfDownloadUrl } = useCompileContext()
   const { pdfDownloadUrl } = useCompileContext()
 
 
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const { t } = useTranslation()
   const { t } = useTranslation()
   const description = pdfDownloadUrl
   const description = pdfDownloadUrl

+ 1 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx

@@ -23,7 +23,7 @@ type PdfJsViewerProps = {
 }
 }
 
 
 function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
 function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const { setError, firstRenderDone, highlights, position, setPosition } =
   const { setError, firstRenderDone, highlights, position, setPosition } =
     useCompileContext()
     useCompileContext()

+ 2 - 1
services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts

@@ -28,7 +28,8 @@ export default function useSynctex(): {
   syncToCodeInFlight: boolean
   syncToCodeInFlight: boolean
   canSyncToPdf: boolean
   canSyncToPdf: boolean
 } {
 } {
-  const { _id: projectId, rootDocId } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const rootDocId = project?.rootDocId
 
 
   const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
   const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
     useCompileContext()
     useCompileContext()

+ 4 - 4
services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx

@@ -55,7 +55,7 @@ function ReviewModeSwitcher() {
   const mode = useCurrentMode()
   const mode = useCurrentMode()
   const { permissionsLevel } = useIdeReactContext()
   const { permissionsLevel } = useIdeReactContext()
   const { write, trackedWrite } = usePermissionsContext()
   const { write, trackedWrite } = usePermissionsContext()
-  const project = useProjectContext()
+  const { features } = useProjectContext()
   const [showUpgradeModal, setShowUpgradeModal] = useState(false)
   const [showUpgradeModal, setShowUpgradeModal] = useState(false)
   const showViewOption = permissionsLevel === 'readOnly'
   const showViewOption = permissionsLevel === 'readOnly'
   const view = useCodeMirrorViewContext()
   const view = useCodeMirrorViewContext()
@@ -100,7 +100,7 @@ function ReviewModeSwitcher() {
                 view.focus()
                 view.focus()
                 return
                 return
               }
               }
-              if (!project.features.trackChanges) {
+              if (!features.trackChanges) {
                 setShowUpgradeModal(true)
                 setShowUpgradeModal(true)
               } else {
               } else {
                 sendMB('editing-mode-change', {
                 sendMB('editing-mode-change', {
@@ -212,7 +212,7 @@ const ModeSwitcherToggleButtonContent = forwardRef<
   })
   })
 
 
   const user = useUserContext()
   const user = useUserContext()
-  const project = useProjectContext()
+  const { features } = useProjectContext()
   const { reviewPanelOpen } = useLayoutContext()
   const { reviewPanelOpen } = useLayoutContext()
   const { inactiveTutorials } = useEditorContext()
   const { inactiveTutorials } = useEditorContext()
 
 
@@ -222,7 +222,7 @@ const ModeSwitcherToggleButtonContent = forwardRef<
   const canShowReviewModePromo =
   const canShowReviewModePromo =
     reviewPanelOpen &&
     reviewPanelOpen &&
     currentMode !== 'review' &&
     currentMode !== 'review' &&
-    project.features.trackChanges &&
+    features.trackChanges &&
     user.signUpDate &&
     user.signUpDate &&
     user.signUpDate < '2025-03-15' &&
     user.signUpDate < '2025-03-15' &&
     !hasCompletedReviewModeTutorial
     !hasCompletedReviewModeTutorial

+ 3 - 3
services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx

@@ -26,7 +26,7 @@ function UpgradeTrackChangesModal({
   setShow,
   setShow,
 }: UpgradeTrackChangesModalProps) {
 }: UpgradeTrackChangesModalProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const project = useProjectContext()
+  const { project } = useProjectContext()
   const user = useUserContext()
   const user = useUserContext()
 
 
   return (
   return (
@@ -62,9 +62,9 @@ function UpgradeTrackChangesModal({
             </ul>
             </ul>
           </OLCol>
           </OLCol>
         </OLRow>
         </OLRow>
-        {project.owner && (
+        {Boolean(project?.owner) && (
           <div className="text-center">
           <div className="text-center">
-            {project.owner._id === user.id ? (
+            {project?.owner._id === user.id ? (
               user.allowedFreeTrial ? (
               user.allowedFreeTrial ? (
                 <OLButton
                 <OLButton
                   variant="premium"
                   variant="premium"

+ 5 - 1
services/web/frontend/js/features/review-panel-new/context/changes-users-context.tsx

@@ -29,7 +29,8 @@ export const ChangesUsersContext = createContext<ChangesUsers | undefined>(
 export const ChangesUsersProvider: FC<React.PropsWithChildren> = ({
 export const ChangesUsersProvider: FC<React.PropsWithChildren> = ({
   children,
   children,
 }) => {
 }) => {
-  const { _id: projectId, members, owner } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const { members, owner } = project || {}
   const { isRestrictedTokenMember } = useEditorContext()
   const { isRestrictedTokenMember } = useEditorContext()
 
 
   const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
   const [changesUsers, setChangesUsers] = useState<ChangesUsers>()
@@ -49,6 +50,9 @@ export const ChangesUsersProvider: FC<React.PropsWithChildren> = ({
 
 
   // add the project owner and members to the changes users data
   // add the project owner and members to the changes users data
   const value = useMemo(() => {
   const value = useMemo(() => {
+    if (!owner || !members) {
+      return
+    }
     const value: ChangesUsers = new Map(changesUsers)
     const value: ChangesUsers = new Map(changesUsers)
     value.set(owner._id, { ...owner, id: owner._id })
     value.set(owner._id, { ...owner, id: owner._id })
     for (const member of members) {
     for (const member of members) {

+ 1 - 1
services/web/frontend/js/features/review-panel-new/context/threads-context.tsx

@@ -49,7 +49,7 @@ const ThreadsActionsContext = createContext<ThreadsActions | undefined>(
 )
 )
 
 
 export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
 export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { currentDocument } = useEditorOpenDocContext()
   const { currentDocument } = useEditorOpenDocContext()
   const { isRestrictedTokenMember } = useEditorContext()
   const { isRestrictedTokenMember } = useEditorContext()
 
 

+ 8 - 8
services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx

@@ -15,7 +15,7 @@ import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-
 import { useUserContext } from '@/shared/context/user-context'
 import { useUserContext } from '@/shared/context/user-context'
 import { postJSON } from '@/infrastructure/fetch-json'
 import { postJSON } from '@/infrastructure/fetch-json'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import useEventListener from '@/shared/hooks/use-event-listener'
-import { ProjectContextValue } from '@/shared/context/types/project-context'
+import { ProjectMetadata } from '@/shared/context/types/project-metadata'
 import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
 import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
 
 
 export type TrackChangesState = {
 export type TrackChangesState = {
@@ -48,14 +48,14 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
 }) => {
 }) => {
   const permissions = usePermissionsContext()
   const permissions = usePermissionsContext()
   const { socket } = useConnectionContext()
   const { socket } = useConnectionContext()
-  const project = useProjectContext()
+  const { projectId, project, features } = useProjectContext()
   const user = useUserContext()
   const user = useUserContext()
   const { setWantTrackChanges } = useEditorPropertiesContext()
   const { setWantTrackChanges } = useEditorPropertiesContext()
 
 
   // TODO: update project.trackChangesState instead?
   // TODO: update project.trackChangesState instead?
   const [trackChangesValue, setTrackChangesValue] = useState<
   const [trackChangesValue, setTrackChangesValue] = useState<
-    ProjectContextValue['trackChangesState']
-  >(project.trackChangesState ?? false)
+    ProjectMetadata['trackChangesState']
+  >(project?.trackChangesState ?? false)
 
 
   useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue)
   useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue)
 
 
@@ -88,11 +88,11 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
 
 
   const saveTrackChanges = useCallback(
   const saveTrackChanges = useCallback(
     async (trackChangesBody: SaveTrackChangesRequestBody) => {
     async (trackChangesBody: SaveTrackChangesRequestBody) => {
-      postJSON(`/project/${project._id}/track_changes`, {
+      postJSON(`/project/${projectId}/track_changes`, {
         body: trackChangesBody,
         body: trackChangesBody,
       })
       })
     },
     },
-    [project._id]
+    [projectId]
   )
   )
 
 
   const saveTrackChangesForCurrentUser = useCallback(
   const saveTrackChangesForCurrentUser = useCallback(
@@ -122,7 +122,7 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
     useCallback(() => {
     useCallback(() => {
       if (
       if (
         user.id &&
         user.id &&
-        project.features.trackChanges &&
+        features.trackChanges &&
         permissions.write &&
         permissions.write &&
         !onForEveryone
         !onForEveryone
       ) {
       ) {
@@ -139,7 +139,7 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
       onForMembers,
       onForMembers,
       onForEveryone,
       onForEveryone,
       permissions.write,
       permissions.write,
-      project.features.trackChanges,
+      features.trackChanges,
       user.id,
       user.id,
     ])
     ])
   )
   )

+ 1 - 1
services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts

@@ -23,7 +23,7 @@ const safeParse = (value: string) => {
 }
 }
 
 
 export default function useOverviewFileCollapsed(docId: DocId) {
 export default function useOverviewFileCollapsed(docId: DocId) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [collapsedDocs, setCollapsedDocs] = usePersistedState<
   const [collapsedDocs, setCollapsedDocs] = usePersistedState<
     Record<DocId, boolean>,
     Record<DocId, boolean>,
     string
     string

+ 1 - 1
services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts

@@ -6,7 +6,7 @@ import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 
 
 export default function useProjectRanges() {
 export default function useProjectRanges() {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [error, setError] = useState<Error>()
   const [error, setError] = useState<Error>()
   const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
   const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
   const [loading, setLoading] = useState(true)
   const [loading, setLoading] = useState(true)

+ 9 - 10
services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx

@@ -23,9 +23,10 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
 
 
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
-  const { updateProject, setInFlight, setError } = useShareProjectContext()
+  const { setInFlight, setError } = useShareProjectContext()
 
 
-  const { _id: projectId, members, invites, features } = useProjectContext()
+  const { projectId, project, features, updateProject } = useProjectContext()
+  const { members, invites } = project || {}
 
 
   const currentMemberEmails = useMemo(
   const currentMemberEmails = useMemo(
     () => (members || []).map(member => member.email).sort(),
     () => (members || []).map(member => member.email).sort(),
@@ -82,9 +83,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
       let data
       let data
 
 
       try {
       try {
-        const invite = (invites || []).find(
-          invite => invite.email === normalisedEmail
-        )
+        const invite = invites?.find(invite => invite.email === normalisedEmail)
 
 
         if (invite) {
         if (invite) {
           data = await resendInvite(projectId, invite)
           data = await resendInvite(projectId, invite)
@@ -109,8 +108,8 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
           // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
           // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
           successful_invite: !!data.invite,
           successful_invite: !!data.invite,
           users_updated: !!(data.users || data.user),
           users_updated: !!(data.users || data.user),
-          current_collaborators_amount: members.length,
-          current_invites_amount: invites.length,
+          current_collaborators_amount: members?.length || 0,
+          current_invites_amount: invites?.length || 0,
           role,
           role,
           previousEditorsAmount,
           previousEditorsAmount,
           previousReviewersAmount,
           previousReviewersAmount,
@@ -144,15 +143,15 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
         setInFlight(false)
         setInFlight(false)
       } else if (data.invite) {
       } else if (data.invite) {
         updateProject({
         updateProject({
-          invites: invites.concat(data.invite),
+          invites: invites?.concat(data.invite) || [data.invite],
         })
         })
       } else if (data.users) {
       } else if (data.users) {
         updateProject({
         updateProject({
-          members: members.concat(data.users),
+          members: members?.concat(data.users) || data.users,
         })
         })
       } else if (data.user) {
       } else if (data.user) {
         updateProject({
         updateProject({
-          members: members.concat(data.user),
+          members: members?.concat(data.user) || [data.user],
         })
         })
       }
       }
 
 

+ 12 - 9
services/web/frontend/js/features/share-project-modal/components/edit-member.tsx

@@ -6,7 +6,7 @@ import { removeMemberFromProject, updateMember } from '../utils/api'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useProjectContext } from '@/shared/context/project-context'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { Select } from '@/shared/components/select'
 import { Select } from '@/shared/components/select'
-import type { ProjectContextMember } from '@/shared/context/types/project-context'
+import type { ProjectMember } from '@/shared/context/types/project-metadata'
 import { PermissionsLevel } from '@/features/ide-react/types/permissions'
 import { PermissionsLevel } from '@/features/ide-react/types/permissions'
 import { linkSharingEnforcementDate } from '../utils/link-sharing'
 import { linkSharingEnforcementDate } from '../utils/link-sharing'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
@@ -19,7 +19,7 @@ import { upgradePlan } from '@/main/account-upgrade'
 type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
 type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
 
 
 type EditMemberProps = {
 type EditMemberProps = {
-  member: ProjectContextMember
+  member: ProjectMember
   hasExceededCollaboratorLimit: boolean
   hasExceededCollaboratorLimit: boolean
   hasBeenDowngraded: boolean
   hasBeenDowngraded: boolean
   canAddCollaborators: boolean
   canAddCollaborators: boolean
@@ -51,8 +51,9 @@ export default function EditMember({
     setPrivileges(member.privileges)
     setPrivileges(member.privileges)
   }, [member.privileges])
   }, [member.privileges])
 
 
-  const { updateProject, monitorRequest } = useShareProjectContext()
-  const { _id: projectId, members, invites } = useProjectContext()
+  const { monitorRequest } = useShareProjectContext()
+  const { projectId, project, updateProject } = useProjectContext()
+  const { members, invites } = project || {}
   const user = useUserContext()
   const user = useUserContext()
 
 
   // Immediately commit this change if it's lower impact (eg. editor > viewer)
   // Immediately commit this change if it's lower impact (eg. editor > viewer)
@@ -88,14 +89,15 @@ export default function EditMember({
     } else if (newPrivileges === 'removeAccess') {
     } else if (newPrivileges === 'removeAccess') {
       monitorRequest(() => removeMemberFromProject(projectId, member)).then(
       monitorRequest(() => removeMemberFromProject(projectId, member)).then(
         () => {
         () => {
-          const updatedMembers = members.filter(existing => existing !== member)
+          const updatedMembers =
+            members?.filter(existing => existing !== member) || []
           updateProject({
           updateProject({
             members: updatedMembers,
             members: updatedMembers,
           })
           })
           sendMB('collaborator-removed', {
           sendMB('collaborator-removed', {
             project_id: projectId,
             project_id: projectId,
             current_collaborators_amount: updatedMembers.length,
             current_collaborators_amount: updatedMembers.length,
-            current_invites_amount: invites.length,
+            current_invites_amount: invites?.length || 0,
           })
           })
         }
         }
       )
       )
@@ -110,9 +112,10 @@ export default function EditMember({
         })
         })
       ).then(() => {
       ).then(() => {
         updateProject({
         updateProject({
-          members: members.map(item =>
-            item._id === member._id ? { ...item, newPrivileges } : item
-          ),
+          members:
+            members?.map(item =>
+              item._id === member._id ? { ...item, newPrivileges } : item
+            ) || [],
         })
         })
       })
       })
     }
     }

+ 8 - 2
services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx

@@ -12,7 +12,8 @@ const EditorOverLimitModal = () => {
 
 
   const { isProjectOwner } = useEditorContext()
   const { isProjectOwner } = useEditorContext()
   const { permissionsLevel } = useIdeReactContext()
   const { permissionsLevel } = useIdeReactContext()
-  const { members, features, _id: projectId } = useProjectContext()
+  const { project, features, projectId } = useProjectContext()
+  const members = project?.members
 
 
   const handleHide = () => {
   const handleHide = () => {
     setShow(false)
     setShow(false)
@@ -24,7 +25,12 @@ const EditorOverLimitModal = () => {
   useEffect(() => {
   useEffect(() => {
     const showModalCooldownHours = 24
     const showModalCooldownHours = 24
     const hasExceededCollaboratorLimit = () => {
     const hasExceededCollaboratorLimit = () => {
-      if (isProjectOwner || !features || permissionsLevel === 'readOnly') {
+      if (
+        isProjectOwner ||
+        !features ||
+        !members ||
+        permissionsLevel === 'readOnly'
+      ) {
         return false
         return false
       }
       }
 
 

+ 11 - 9
services/web/frontend/js/features/share-project-modal/components/invite.tsx

@@ -10,13 +10,13 @@ import OLCol from '@/features/ui/components/ol/ol-col'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
-import { ProjectContextMember } from '@/shared/context/types/project-context'
+import { ProjectMember } from '@/shared/context/types/project-metadata'
 
 
 export default function Invite({
 export default function Invite({
   invite,
   invite,
   isProjectOwner,
   isProjectOwner,
 }: {
 }: {
-  invite: ProjectContextMember
+  invite: ProjectMember
   isProjectOwner: boolean
   isProjectOwner: boolean
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -44,10 +44,10 @@ export default function Invite({
   )
   )
 }
 }
 
 
-function ResendInvite({ invite }: { invite: ProjectContextMember }) {
+function ResendInvite({ invite }: { invite: ProjectMember }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { monitorRequest, setError, inFlight } = useShareProjectContext()
   const { monitorRequest, setError, inFlight } = useShareProjectContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   // const buttonRef = useRef(null)
   // const buttonRef = useRef(null)
   //
   //
@@ -87,23 +87,25 @@ function ResendInvite({ invite }: { invite: ProjectContextMember }) {
   )
   )
 }
 }
 
 
-function RevokeInvite({ invite }: { invite: ProjectContextMember }) {
+function RevokeInvite({ invite }: { invite: ProjectMember }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { updateProject, monitorRequest } = useShareProjectContext()
-  const { _id: projectId, invites, members } = useProjectContext()
+  const { monitorRequest } = useShareProjectContext()
+  const { projectId, project, updateProject } = useProjectContext()
+  const { invites, members } = project || {}
 
 
   function handleClick(event: React.MouseEvent) {
   function handleClick(event: React.MouseEvent) {
     event.preventDefault()
     event.preventDefault()
 
 
     monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
     monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
-      const updatedInvites = invites.filter(existing => existing !== invite)
+      const updatedInvites =
+        invites?.filter(existing => existing !== invite) || []
       updateProject({
       updateProject({
         invites: updatedInvites,
         invites: updatedInvites,
       })
       })
       sendMB('collaborator-invite-revoked', {
       sendMB('collaborator-invite-revoked', {
         project_id: projectId,
         project_id: projectId,
         current_invites_amount: updatedInvites.length,
         current_invites_amount: updatedInvites.length,
-        current_collaborators_amount: members.length,
+        current_collaborators_amount: members?.length || 0,
       })
       })
     })
     })
   }
   }

+ 4 - 3
services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx

@@ -33,7 +33,8 @@ export default function LinkSharing() {
 
 
   const { monitorRequest } = useShareProjectContext()
   const { monitorRequest } = useShareProjectContext()
 
 
-  const { _id: projectId, publicAccessLevel } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const { publicAccessLevel } = project || {}
 
 
   // set the access level of a project
   // set the access level of a project
   const setAccessLevel = useCallback(
   const setAccessLevel = useCallback(
@@ -145,7 +146,7 @@ function TokenBasedSharing({
   showLinks: boolean
   showLinks: boolean
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const [tokens, setTokens] = useState<Tokens | null>(null)
   const [tokens, setTokens] = useState<Tokens | null>(null)
 
 
@@ -244,7 +245,7 @@ function LegacySharing({
 
 
 export function ReadOnlyTokenLink() {
 export function ReadOnlyTokenLink() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   const [tokens, setTokens] = useState<Tokens | null>(null)
   const [tokens, setTokens] = useState<Tokens | null>(null)
 
 

+ 2 - 2
services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx

@@ -1,10 +1,10 @@
-import { ProjectContextMember } from '@/shared/context/types/project-context'
+import { ProjectMember } from '@/shared/context/types/project-metadata'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 
 
 export default function MemberPrivileges({
 export default function MemberPrivileges({
   privileges,
   privileges,
 }: {
 }: {
-  privileges: ProjectContextMember['privileges']
+  privileges: ProjectMember['privileges']
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 

+ 2 - 2
services/web/frontend/js/features/share-project-modal/components/owner-info.tsx

@@ -6,14 +6,14 @@ import MaterialIcon from '@/shared/components/material-icon'
 
 
 export default function OwnerInfo() {
 export default function OwnerInfo() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { owner } = useProjectContext()
+  const { project } = useProjectContext()
 
 
   return (
   return (
     <OLRow className="project-member">
     <OLRow className="project-member">
       <OLCol xs={8}>
       <OLCol xs={8}>
         <div className="project-member-email-icon">
         <div className="project-member-email-icon">
           <MaterialIcon type="person" />
           <MaterialIcon type="person" />
-          <div className="email-warning">{owner?.email}</div>
+          <div className="email-warning">{project?.owner.email}</div>
         </div>
         </div>
       </OLCol>
       </OLCol>
       <OLCol xs={4} className="text-end">
       <OLCol xs={4} className="text-end">

+ 2 - 1
services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx

@@ -7,7 +7,8 @@ import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'
 import OLCol from '@/features/ui/components/ol/ol-col'
 
 
 export default function SendInvitesNotice() {
 export default function SendInvitesNotice() {
-  const { publicAccessLevel } = useProjectContext()
+  const { project } = useProjectContext()
+  const { publicAccessLevel } = project || {}
   const { isPendingEditor } = useEditorContext()
   const { isPendingEditor } = useEditorContext()
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 

+ 17 - 12
services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx

@@ -12,7 +12,8 @@ import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
 
 
 export default function ShareModalBody() {
 export default function ShareModalBody() {
-  const { members, invites, features } = useProjectContext()
+  const { project, features } = useProjectContext()
+  const { members, invites } = project || {}
   const { isProjectOwner } = useEditorContext()
   const { isProjectOwner } = useEditorContext()
 
 
   // whether the project has not reached the collaborator limit
   // whether the project has not reached the collaborator limit
@@ -26,12 +27,12 @@ export default function ShareModalBody() {
       return true
       return true
     }
     }
 
 
-    const editorInvites = invites.filter(
-      invite => invite.privileges !== 'readOnly'
-    ).length
+    const editorInvites =
+      invites?.filter(invite => invite.privileges !== 'readOnly').length || 0
 
 
     return (
     return (
-      members.filter(member => member.privileges !== 'readOnly').length +
+      (members?.filter(member => member.privileges !== 'readOnly').length ||
+        0) +
         editorInvites <
         editorInvites <
       (features.collaborators ?? 1)
       (features.collaborators ?? 1)
     )
     )
@@ -40,11 +41,11 @@ export default function ShareModalBody() {
   // determine if some but not all pending editors' permissions have been resolved,
   // determine if some but not all pending editors' permissions have been resolved,
   // for moving between warning and info notification states etc.
   // for moving between warning and info notification states etc.
   const somePendingEditorsResolved = useMemo(() => {
   const somePendingEditorsResolved = useMemo(() => {
-    return (
-      members.some(member =>
+    return Boolean(
+      members?.some(member =>
         ['readAndWrite', 'review'].includes(member.privileges)
         ['readAndWrite', 'review'].includes(member.privileges)
       ) &&
       ) &&
-      members.some(member => member.pendingEditor || member.pendingReviewer)
+        members?.some(member => member.pendingEditor || member.pendingReviewer)
     )
     )
   }, [members])
   }, [members])
 
 
@@ -56,13 +57,14 @@ export default function ShareModalBody() {
     if (features.collaborators === -1) {
     if (features.collaborators === -1) {
       return false
       return false
     }
     }
-    return members.some(
-      member => member.pendingEditor || member.pendingReviewer
+    return (
+      members?.some(member => member.pendingEditor || member.pendingReviewer) ||
+      false
     )
     )
   }, [features, isProjectOwner, members])
   }, [features, isProjectOwner, members])
 
 
   const hasExceededCollaboratorLimit = useMemo(() => {
   const hasExceededCollaboratorLimit = useMemo(() => {
-    if (!isProjectOwner || !features) {
+    if (!isProjectOwner || !features || !members) {
       return false
       return false
     }
     }
 
 
@@ -77,6 +79,9 @@ export default function ShareModalBody() {
   }, [features, isProjectOwner, members])
   }, [features, isProjectOwner, members])
 
 
   const sortedMembers = useMemo(() => {
   const sortedMembers = useMemo(() => {
+    if (!members) {
+      return []
+    }
     return [
     return [
       ...members.filter(member => member.privileges === 'readAndWrite'),
       ...members.filter(member => member.privileges === 'readAndWrite'),
       ...members.filter(member => member.pendingEditor),
       ...members.filter(member => member.pendingEditor),
@@ -126,7 +131,7 @@ export default function ShareModalBody() {
         )
         )
       )}
       )}
 
 
-      {invites.map(invite => (
+      {(invites || []).map(invite => (
         <Invite
         <Invite
           key={invite._id}
           key={invite._id}
           invite={invite}
           invite={invite}

+ 6 - 15
services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx

@@ -9,12 +9,10 @@ import ShareProjectModalContent from './share-project-modal-content'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useSplitTestContext } from '@/shared/context/split-test-context'
 import { useSplitTestContext } from '@/shared/context/split-test-context'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { sendMB } from '@/infrastructure/event-tracking'
-import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
 import { useEditorContext } from '@/shared/context/editor-context'
 import { useEditorContext } from '@/shared/context/editor-context'
 import customLocalStorage from '@/infrastructure/local-storage'
 import customLocalStorage from '@/infrastructure/local-storage'
 
 
 type ShareProjectContextValue = {
 type ShareProjectContextValue = {
-  updateProject: (project: ProjectContextUpdateValue) => void
   monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
   monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
   inFlight: boolean
   inFlight: boolean
   setInFlight: React.Dispatch<
   setInFlight: React.Dispatch<
@@ -61,7 +59,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     useState<ShareProjectContextValue['inFlight']>(false)
     useState<ShareProjectContextValue['inFlight']>(false)
   const [error, setError] = useState<ShareProjectContextValue['error']>()
   const [error, setError] = useState<ShareProjectContextValue['error']>()
 
 
-  const project = useProjectContext()
+  const { project, projectId } = useProjectContext()
   const { isProjectOwner } = useEditorContext()
   const { isProjectOwner } = useEditorContext()
 
 
   const { splitTestVariants } = useSplitTestContext()
   const { splitTestVariants } = useSplitTestContext()
@@ -70,7 +68,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   // is over collaborator limit or has pending editors (once every 24 hours)
   // is over collaborator limit or has pending editors (once every 24 hours)
   useEffect(() => {
   useEffect(() => {
     const hasExceededCollaboratorLimit = () => {
     const hasExceededCollaboratorLimit = () => {
-      if (!isProjectOwner || !project.features) {
+      if (!isProjectOwner || !project || !project.features) {
         return false
         return false
       }
       }
 
 
@@ -88,7 +86,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     }
     }
 
 
     if (hasExceededCollaboratorLimit()) {
     if (hasExceededCollaboratorLimit()) {
-      const localStorageKey = `last-shown-share-modal.${project._id}`
+      const localStorageKey = `last-shown-share-modal.${projectId}`
       const lastShownShareModalTime =
       const lastShownShareModalTime =
         customLocalStorage.getItem(localStorageKey)
         customLocalStorage.getItem(localStorageKey)
       if (
       if (
@@ -99,17 +97,17 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
         customLocalStorage.setItem(localStorageKey, Date.now())
         customLocalStorage.setItem(localStorageKey, Date.now())
       }
       }
     }
     }
-  }, [project, isProjectOwner, handleOpen])
+  }, [project, isProjectOwner, handleOpen, projectId])
 
 
   // send tracking event when the modal is opened
   // send tracking event when the modal is opened
   useEffect(() => {
   useEffect(() => {
     if (show) {
     if (show) {
       sendMB('share-modal-opened', {
       sendMB('share-modal-opened', {
         splitTestVariant: splitTestVariants['null-test-share-modal'],
         splitTestVariant: splitTestVariants['null-test-share-modal'],
-        project_id: project._id,
+        project_id: projectId,
       })
       })
     }
     }
-  }, [splitTestVariants, project._id, show])
+  }, [splitTestVariants, projectId, show])
 
 
   // reset error when the modal is opened
   // reset error when the modal is opened
   useEffect(() => {
   useEffect(() => {
@@ -147,12 +145,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     return promise
     return promise
   }, [])
   }, [])
 
 
-  // merge the new data with the old project data
-  const updateProject = useCallback(
-    (data: ProjectContextUpdateValue) => Object.assign(project, data),
-    [project]
-  )
-
   if (!project) {
   if (!project) {
     return null
     return null
   }
   }
@@ -160,7 +152,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   return (
   return (
     <ShareProjectContext.Provider
     <ShareProjectContext.Provider
       value={{
       value={{
-        updateProject,
         monitorRequest,
         monitorRequest,
         inFlight,
         inFlight,
         setInFlight,
         setInFlight,

+ 3 - 3
services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx

@@ -12,13 +12,13 @@ import OLModal, {
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import { Spinner } from 'react-bootstrap'
 import { Spinner } from 'react-bootstrap'
-import { ProjectContextMember } from '@/shared/context/types/project-context'
+import { ProjectMember } from '@/shared/context/types/project-metadata'
 
 
 export default function TransferOwnershipModal({
 export default function TransferOwnershipModal({
   member,
   member,
   cancel,
   cancel,
 }: {
 }: {
-  member: ProjectContextMember
+  member: ProjectMember
   cancel: () => void
   cancel: () => void
 }) {
 }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -27,7 +27,7 @@ export default function TransferOwnershipModal({
   const [error, setError] = useState(false)
   const [error, setError] = useState(false)
   const location = useLocation()
   const location = useLocation()
 
 
-  const { _id: projectId, name: projectName } = useProjectContext()
+  const { projectId, name: projectName } = useProjectContext()
 
 
   function confirm() {
   function confirm() {
     setError(false)
     setError(false)

+ 2 - 6
services/web/frontend/js/features/share-project-modal/components/view-member.tsx

@@ -2,13 +2,9 @@ import MemberPrivileges from './member-privileges'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'
 import OLCol from '@/features/ui/components/ol/ol-col'
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
-import { ProjectContextMember } from '@/shared/context/types/project-context'
+import { ProjectMember } from '@/shared/context/types/project-metadata'
 
 
-export default function ViewMember({
-  member,
-}: {
-  member: ProjectContextMember
-}) {
+export default function ViewMember({ member }: { member: ProjectMember }) {
   return (
   return (
     <OLRow className="project-member">
     <OLRow className="project-member">
       <OLCol xs={8}>
       <OLCol xs={8}>

+ 2 - 1
services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx

@@ -12,7 +12,8 @@ const ViewOnlyAccessModal = () => {
 
 
   const { isProjectOwner, isPendingEditor } = useEditorContext()
   const { isProjectOwner, isPendingEditor } = useEditorContext()
   const { permissionsLevel } = useIdeReactContext()
   const { permissionsLevel } = useIdeReactContext()
-  const { members, features, _id: projectId } = useProjectContext()
+  const { features, projectId, project } = useProjectContext()
+  const members = project?.members
 
 
   const handleHide = () => {
   const handleHide = () => {
     setShow(false)
     setShow(false)

+ 1 - 1
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-other-project-source.tsx

@@ -34,7 +34,7 @@ export const FigureModalOtherProjectSource: FC = () => {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const view = useCodeMirrorViewContext()
   const view = useCodeMirrorViewContext()
   const { dispatch } = useFigureModalContext()
   const { dispatch } = useFigureModalContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { loading: projectsLoading, data: projects, error } = useUserProjects()
   const { loading: projectsLoading, data: projects, error } = useUserProjects()
   const [selectedProject, setSelectedProject] = useState<null | Project>(null)
   const [selectedProject, setSelectedProject] = useState<null | Project>(null)
   const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =
   const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } =

+ 1 - 1
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx

@@ -34,7 +34,7 @@ export const FigureModalUploadFileSource: FC = () => {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const view = useCodeMirrorViewContext()
   const view = useCodeMirrorViewContext()
   const { dispatch, pastedImageData } = useFigureModalContext()
   const { dispatch, pastedImageData } = useFigureModalContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { rootFile } = useCurrentProjectFolders()
   const { rootFile } = useCurrentProjectFolders()
   const [folder, setFolder] = useState<File | null>(null)
   const [folder, setFolder] = useState<File | null>(null)
   const [nameDirty, setNameDirty] = useState<boolean>(false)
   const [nameDirty, setNameDirty] = useState<boolean>(false)

+ 1 - 1
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx

@@ -46,7 +46,7 @@ export const FigureModalUrlSource: FC = () => {
   const [url, setUrl] = useState<string>('')
   const [url, setUrl] = useState<string>('')
   const [nameDirty, setNameDirty] = useState<boolean>(false)
   const [nameDirty, setNameDirty] = useState<boolean>(false)
   const [name, setName] = useState<string>('')
   const [name, setName] = useState<string>('')
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { rootFile } = useCurrentProjectFolders()
   const { rootFile } = useCurrentProjectFolders()
   const [folder, setFolder] = useState<File>(rootFile)
   const [folder, setFolder] = useState<File>(rootFile)
 
 

+ 3 - 7
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -1,6 +1,5 @@
 import { useCallback, useEffect, useRef } from 'react'
 import { useCallback, useEffect, useRef } from 'react'
 import { EditorState } from '@codemirror/state'
 import { EditorState } from '@codemirror/state'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
 import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
 import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
 import useEventListener from '../../../shared/hooks/use-event-listener'
 import useEventListener from '../../../shared/hooks/use-event-listener'
 import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
 import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
@@ -55,6 +54,7 @@ import { GotoOffsetOptions } from '@/features/ide-react/context/editor-manager-c
 import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
 import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
 import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
 import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
+import { useProjectContext } from '@/shared/context/project-context'
 import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
 import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
 import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
 import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
 import { SearchQuery } from '@codemirror/search'
 import { SearchQuery } from '@codemirror/search'
@@ -92,17 +92,13 @@ function useCodeMirrorScope(view: EditorView) {
 
 
   const { onlineUserCursorHighlights } = useOnlineUsersContext()
   const { onlineUserCursorHighlights } = useOnlineUsersContext()
 
 
-  let [spellCheckLanguage] = useScopeValue<string>('project.spellCheckLanguage')
+  const { project, features: projectFeatures } = useProjectContext()
+  let spellCheckLanguage = project?.spellCheckLanguage || ''
   // spell check is off when read-only
   // spell check is off when read-only
   if (!permissions.write && !permissions.trackedWrite) {
   if (!permissions.write && !permissions.trackedWrite) {
     spellCheckLanguage = ''
     spellCheckLanguage = ''
   }
   }
 
 
-  const [projectFeatures] =
-    useScopeValue<Record<string, boolean | string | number | undefined>>(
-      'project.features'
-    )
-
   const hunspellManager = useHunspell(spellCheckLanguage)
   const hunspellManager = useHunspell(spellCheckLanguage)
 
 
   const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
   const { showVisual: visual, trackChanges } = useEditorPropertiesContext()

+ 2 - 1
services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx

@@ -19,7 +19,8 @@ export const WordCountClient: FC = () => {
   const [loading, setLoading] = useState(true)
   const [loading, setLoading] = useState(true)
   const [error, setError] = useState(false)
   const [error, setError] = useState(false)
   const [data, setData] = useState<WordCountData | null>(null)
   const [data, setData] = useState<WordCountData | null>(null)
-  const { projectSnapshot, rootDocId } = useProjectContext()
+  const { projectSnapshot, project } = useProjectContext()
+  const rootDocId = project?.rootDocId
   const { spellCheckLanguage } = useProjectSettingsContext()
   const { spellCheckLanguage } = useProjectSettingsContext()
   const { openDocs } = useEditorManagerContext()
   const { openDocs } = useEditorManagerContext()
   const { currentDocument } = useEditorOpenDocContext()
   const { currentDocument } = useEditorOpenDocContext()

+ 1 - 1
services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx

@@ -10,7 +10,7 @@ import { debugConsole } from '@/utils/debugging'
 import { WordCounts } from '@/features/word-count-modal/components/word-counts'
 import { WordCounts } from '@/features/word-count-modal/components/word-counts'
 
 
 export const WordCountServer: FC = () => {
 export const WordCountServer: FC = () => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { clsiServerId } = useLocalCompileContext()
   const { clsiServerId } = useLocalCompileContext()
 
 
   const [loading, setLoading] = useState(true)
   const [loading, setLoading] = useState(true)

+ 30 - 34
services/web/frontend/js/shared/context/editor-context.tsx

@@ -9,9 +9,7 @@ import {
   useMemo,
   useMemo,
   useState,
   useState,
 } from 'react'
 } from 'react'
-import useScopeValue from '../hooks/use-scope-value'
 import useBrowserWindow from '../hooks/use-browser-window'
 import useBrowserWindow from '../hooks/use-browser-window'
-import { useIdeContext } from './ide-context'
 import { useProjectContext } from './project-context'
 import { useProjectContext } from './project-context'
 import { useDetachContext } from './detach-context'
 import { useDetachContext } from './detach-context'
 import getMeta from '../../utils/meta'
 import getMeta from '../../utils/meta'
@@ -46,12 +44,18 @@ export const EditorContext = createContext<
 >(undefined)
 >(undefined)
 
 
 export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
 export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
-  const { socket } = useIdeContext()
   const { id: userId, featureUsage } = useUserContext()
   const { id: userId, featureUsage } = useUserContext()
   const { role } = useDetachContext()
   const { role } = useDetachContext()
   const { showGenericMessageModal } = useModalsContext()
   const { showGenericMessageModal } = useModalsContext()
 
 
-  const { owner, features, _id: projectId, members } = useProjectContext()
+  const {
+    features,
+    projectId,
+    project,
+    name: projectName,
+    updateProject,
+  } = useProjectContext()
+  const { owner, members } = project || {}
 
 
   const cobranding = useMemo(() => {
   const cobranding = useMemo(() => {
     const brandVariation = getMeta('ol-brandVariation')
     const brandVariation = getMeta('ol-brandVariation')
@@ -71,8 +75,6 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
     )
     )
   }, [])
   }, [])
 
 
-  const [projectName, setProjectName] = useScopeValue('project.name')
-
   const [inactiveTutorials, setInactiveTutorials] = useState(
   const [inactiveTutorials, setInactiveTutorials] = useState(
     () => getMeta('ol-inactiveTutorials') || []
     () => getMeta('ol-inactiveTutorials') || []
   )
   )
@@ -95,10 +97,12 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
 
 
   const isPendingEditor = useMemo(
   const isPendingEditor = useMemo(
     () =>
     () =>
-      members?.some(
-        member =>
-          member._id === userId &&
-          (member.pendingEditor || member.pendingReviewer)
+      Boolean(
+        members?.some(
+          member =>
+            member._id === userId &&
+            (member.pendingEditor || member.pendingReviewer)
+        )
       ),
       ),
     [members, userId]
     [members, userId]
   )
   )
@@ -110,33 +114,25 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
     [inactiveTutorials]
     [inactiveTutorials]
   )
   )
 
 
-  useEffect(() => {
-    if (socket) {
-      socket.on('projectNameUpdated', setProjectName)
-      return () => socket.removeListener('projectNameUpdated', setProjectName)
-    }
-  }, [socket, setProjectName])
-
   const renameProject = useCallback(
   const renameProject = useCallback(
     (newName: string) => {
     (newName: string) => {
-      setProjectName((oldName: string) => {
-        if (oldName !== newName) {
-          saveProjectSettings(projectId, { name: newName }).catch(
-            (response: any) => {
-              setProjectName(oldName)
-              const { data, status } = response
-
-              showGenericMessageModal(
-                'Error renaming project',
-                status === 400 ? data : 'Please try again in a moment'
-              )
-            }
-          )
-        }
-        return newName
-      })
+      const oldName = projectName
+      if (newName !== oldName) {
+        updateProject({ name: newName })
+        saveProjectSettings(projectId, { name: newName }).catch(
+          (response: any) => {
+            updateProject({ name: oldName })
+            const { data, status } = response
+
+            showGenericMessageModal(
+              'Error renaming project',
+              status === 400 ? data : 'Please try again in a moment'
+            )
+          }
+        )
+      }
     },
     },
-    [setProjectName, projectId, showGenericMessageModal]
+    [projectName, updateProject, projectId, showGenericMessageModal]
   )
   )
 
 
   const { setTitle } = useBrowserWindow()
   const { setTitle } = useBrowserWindow()

+ 3 - 4
services/web/frontend/js/shared/context/file-tree-data-context.tsx

@@ -8,7 +8,6 @@ import {
   FC,
   FC,
   useEffect,
   useEffect,
 } from 'react'
 } from 'react'
-import useScopeValue from '../hooks/use-scope-value'
 import {
 import {
   renameInTree,
   renameInTree,
   deleteInTree,
   deleteInTree,
@@ -20,7 +19,6 @@ import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect'
 import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
 import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { Folder } from '../../../../types/folder'
 import { Folder } from '../../../../types/folder'
-import { Project } from '../../../../types/project'
 import { MainDocument } from '../../../../types/project-settings'
 import { MainDocument } from '../../../../types/project-settings'
 import { FindResult } from '@/features/file-tree/util/path'
 import { FindResult } from '@/features/file-tree/util/path'
 import {
 import {
@@ -28,6 +26,7 @@ import {
   useSnapshotContext,
   useSnapshotContext,
 } from '@/features/ide-react/context/snapshot-context'
 } from '@/features/ide-react/context/snapshot-context'
 import importOverleafModules from '../../../macros/import-overleaf-module.macro'
 import importOverleafModules from '../../../macros/import-overleaf-module.macro'
+import { useProjectContext } from '@/shared/context/project-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 
 
 const { buildFileTree, createFolder } =
 const { buildFileTree, createFolder } =
@@ -182,7 +181,7 @@ export function useFileTreeData() {
 export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
 export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
   children,
   children,
 }) => {
 }) => {
-  const [project] = useScopeValue<Project>('project')
+  const { project } = useProjectContext()
   const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext()
   const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext()
   const { permissionsLevel } = useIdeReactContext()
   const { permissionsLevel } = useIdeReactContext()
   const { fileTreeFromHistory, snapshot, snapshotVersion } =
   const { fileTreeFromHistory, snapshot, snapshotVersion } =
@@ -195,7 +194,7 @@ export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
   useEffect(() => {
   useEffect(() => {
     if (fileTreeFromHistory) return
     if (fileTreeFromHistory) return
     setRootFolder(project?.rootFolder)
     setRootFolder(project?.rootFolder)
-  }, [project, fileTreeFromHistory])
+  }, [project?.rootFolder, fileTreeFromHistory])
 
 
   useEffect(() => {
   useEffect(() => {
     if (!fileTreeFromHistory) return
     if (!fileTreeFromHistory) return

+ 2 - 7
services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -131,13 +131,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
   const { currentDocument } = useEditorOpenDocContext()
   const { currentDocument } = useEditorOpenDocContext()
   const { role } = useDetachContext()
   const { role } = useDetachContext()
 
 
-  const {
-    _id: projectId,
-    rootDocId,
-    joinedOnce,
-    imageName,
-    compiler: compilerName,
-  } = useProjectContext()
+  const { projectId, joinedOnce, project } = useProjectContext()
+  const { rootDocId, imageName, compiler: compilerName } = project || {}
 
 
   const { pdfPreviewOpen } = useLayoutContext()
   const { pdfPreviewOpen } = useLayoutContext()
 
 

+ 59 - 64
services/web/frontend/js/shared/context/project-context.tsx

@@ -1,10 +1,31 @@
-import { FC, createContext, useContext, useMemo, useState } from 'react'
-import useScopeValue from '../hooks/use-scope-value'
+import {
+  FC,
+  createContext,
+  useContext,
+  useMemo,
+  useState,
+  useCallback,
+} from 'react'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
-import { ProjectContextValue } from './types/project-context'
+import { ProjectUpdate, ProjectMetadata } from './types/project-metadata'
 import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
 import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
+import { Tag } from '../../../../app/src/Features/Tags/types'
 
 
-const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
+type ProjectContextValue = {
+  projectId: ProjectMetadata['_id']
+  project: ProjectMetadata | null
+  joinProject: (project: ProjectMetadata) => void
+  updateProject: (projectUpdate: ProjectUpdate) => void
+  joinedOnce: boolean
+  projectSnapshot: ProjectSnapshot
+  tags: Tag[]
+  features: ProjectMetadata['features']
+  name: ProjectMetadata['name']
+}
+
+export const ProjectContext = createContext<ProjectContextValue | undefined>(
+  undefined
+)
 
 
 export function useProjectContext() {
 export function useProjectContext() {
   const context = useContext(ProjectContext)
   const context = useContext(ProjectContext)
@@ -18,35 +39,33 @@ export function useProjectContext() {
   return context
   return context
 }
 }
 
 
-// when the provider is created the project is still not added to the Angular
-// scope. A few props are populated to prevent errors in existing React
-// components
-const projectFallback = {
-  _id: getMeta('ol-project_id'),
-  name: '',
-  features: {},
-}
-
 export const ProjectProvider: FC<React.PropsWithChildren> = ({ children }) => {
 export const ProjectProvider: FC<React.PropsWithChildren> = ({ children }) => {
-  const [project] = useScopeValue('project')
-  const joinedOnce = !!project
+  const [joinedOnce, setJoinedOnce] = useState(false)
+  const [project, setProject] = useState<ProjectMetadata | null>(null)
 
 
-  const {
-    _id,
-    compiler,
-    imageName,
-    name,
-    rootDocId,
-    members,
-    invites,
-    features,
-    publicAccesLevel: publicAccessLevel,
-    owner,
-    trackChangesState,
-    mainBibliographyDoc_id: mainBibliographyDocId,
-  } = project || projectFallback
+  // Expose some project properties with fallbacks for convenience
+  const projectId = project ? project._id : getMeta('ol-project_id')
+  const name = project ? project.name : ''
+  const features = project ? project.features : {}
+
+  const joinProject = useCallback((projectData: ProjectMetadata) => {
+    setProject(projectData)
+    setJoinedOnce(true)
+  }, [])
+
+  const updateProject = useCallback((projectUpdateData: ProjectUpdate) => {
+    setProject(projectData => {
+      // Only perform the update if `project` is already set, otherwise we could
+      // end up with an incomplete project object
+      if (!projectData) {
+        throw new Error('Project not initialized. Use joinProject first.')
+      }
+
+      return Object.assign({}, projectData, projectUpdateData)
+    })
+  }, [])
 
 
-  const [projectSnapshot] = useState(() => new ProjectSnapshot(_id))
+  const [projectSnapshot] = useState(() => new ProjectSnapshot(projectId))
 
 
   const tags = useMemo(
   const tags = useMemo(
     () =>
     () =>
@@ -56,41 +75,17 @@ export const ProjectProvider: FC<React.PropsWithChildren> = ({ children }) => {
     []
     []
   )
   )
 
 
-  const value = useMemo(() => {
-    return {
-      _id,
-      compiler,
-      imageName,
-      name,
-      rootDocId,
-      members,
-      invites,
-      features,
-      publicAccessLevel,
-      owner,
-      tags,
-      trackChangesState,
-      mainBibliographyDocId,
-      projectSnapshot,
-      joinedOnce,
-    }
-  }, [
-    _id,
-    compiler,
-    imageName,
-    name,
-    rootDocId,
-    members,
-    invites,
-    features,
-    publicAccessLevel,
-    owner,
-    tags,
-    trackChangesState,
-    mainBibliographyDocId,
-    projectSnapshot,
+  const value = {
+    projectId,
+    project,
+    joinProject,
+    updateProject,
     joinedOnce,
     joinedOnce,
-  ])
+    projectSnapshot,
+    tags,
+    features,
+    name,
+  }
 
 
   return (
   return (
     <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
     <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>

+ 8 - 14
services/web/frontend/js/shared/context/types/project-context.tsx → services/web/frontend/js/shared/context/types/project-metadata.tsx

@@ -1,9 +1,9 @@
 import { UserId } from '../../../../../types/user'
 import { UserId } from '../../../../../types/user'
 import { PublicAccessLevel } from '../../../../../types/public-access-level'
 import { PublicAccessLevel } from '../../../../../types/public-access-level'
-import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
-import { Tag } from '../../../../../app/src/Features/Tags/types'
+import { ProjectSettings } from '@/features/editor-left-menu/utils/api'
+import { Folder } from '../../../../../types/folder'
 
 
-export type ProjectContextMember = {
+export type ProjectMember = {
   _id: UserId
   _id: UserId
   privileges: 'readOnly' | 'readAndWrite' | 'review'
   privileges: 'readOnly' | 'readAndWrite' | 'review'
   email: string
   email: string
@@ -13,15 +13,11 @@ export type ProjectContextMember = {
   pendingReviewer?: boolean
   pendingReviewer?: boolean
 }
 }
 
 
-export type ProjectContextValue = {
+export interface ProjectMetadata extends ProjectSettings {
   _id: string
   _id: string
-  name: string
-  rootDocId?: string
   mainBibliographyDocId?: string
   mainBibliographyDocId?: string
-  compiler: string
-  imageName: string
-  members: ProjectContextMember[]
-  invites: ProjectContextMember[]
+  members: ProjectMember[]
+  invites: ProjectMember[]
   features: {
   features: {
     collaborators?: number
     collaborators?: number
     compileGroup?: 'alpha' | 'standard' | 'priority'
     compileGroup?: 'alpha' | 'standard' | 'priority'
@@ -44,10 +40,8 @@ export type ProjectContextValue = {
     privileges: string
     privileges: string
     signUpDate: string
     signUpDate: string
   }
   }
-  tags: Tag[]
+  rootFolder?: Folder[]
   trackChangesState: boolean | Record<UserId | '__guests__', boolean>
   trackChangesState: boolean | Record<UserId | '__guests__', boolean>
-  projectSnapshot: ProjectSnapshot
-  joinedOnce: boolean
 }
 }
 
 
-export type ProjectContextUpdateValue = Partial<ProjectContextValue>
+export type ProjectUpdate = Partial<ProjectMetadata>

+ 1 - 1
services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts

@@ -10,7 +10,7 @@ type UseStopOnFirstErrorProps = {
 export function useStopOnFirstError(opts: UseStopOnFirstErrorProps = {}) {
 export function useStopOnFirstError(opts: UseStopOnFirstErrorProps = {}) {
   const { eventSource } = opts
   const { eventSource } = opts
   const { stopOnFirstError, setStopOnFirstError } = useCompileContext()
   const { stopOnFirstError, setStopOnFirstError } = useCompileContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
 
   type Opts = {
   type Opts = {
     projectId: string
     projectId: string

+ 48 - 28
services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx

@@ -4,11 +4,16 @@ import {
   OverallThemeMeta,
   OverallThemeMeta,
   SpellCheckLanguage,
   SpellCheckLanguage,
 } from '../../../../types/project-settings'
 } from '../../../../types/project-settings'
-import { EditorProviders } from '../../helpers/editor-providers'
+import {
+  EditorProviders,
+  makeProjectProvider,
+} from '../../helpers/editor-providers'
 import { mockScope } from './scope'
 import { mockScope } from './scope'
 import { Folder } from '../../../../types/folder'
 import { Folder } from '../../../../types/folder'
 import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
 import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
+import { mockProject } from '../../features/source-editor/helpers/mock-project'
+import { UserId } from '../../../../types/user'
 
 
 describe('<EditorLeftMenu />', function () {
 describe('<EditorLeftMenu />', function () {
   beforeEach(function () {
   beforeEach(function () {
@@ -57,9 +62,14 @@ describe('<EditorLeftMenu />', function () {
 
 
     it('render full menu', function () {
     it('render full menu', function () {
       const scope = mockScope()
       const scope = mockScope()
+      const project = mockProject()
 
 
       cy.mount(
       cy.mount(
-        <EditorProviders scope={scope} layoutContext={{ leftMenuShown: true }}>
+        <EditorProviders
+          scope={scope}
+          layoutContext={{ leftMenuShown: true }}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <EditorLeftMenu />
           <EditorLeftMenu />
         </EditorProviders>
         </EditorProviders>
       )
       )
@@ -209,12 +219,6 @@ describe('<EditorLeftMenu />', function () {
         })
         })
 
 
         const scope = mockScope({
         const scope = mockScope({
-          project: {
-            members: [],
-            owner: {
-              _id: '123',
-            },
-          },
           user: {
           user: {
             features: {
             features: {
               dropbox: false,
               dropbox: false,
@@ -226,6 +230,14 @@ describe('<EditorLeftMenu />', function () {
           <EditorProviders
           <EditorProviders
             scope={scope}
             scope={scope}
             layoutContext={{ leftMenuShown: true }}
             layoutContext={{ leftMenuShown: true }}
+            projectOwner={{
+              _id: '123' as UserId,
+              email: 'owner@example.com',
+              first_name: 'Test',
+              last_name: 'Owner',
+              privileges: 'owner',
+              signUpDate: new Date('2025-07-07').toISOString(),
+            }}
           >
           >
             <EditorLeftMenu />
             <EditorLeftMenu />
           </EditorProviders>
           </EditorProviders>
@@ -236,21 +248,25 @@ describe('<EditorLeftMenu />', function () {
       })
       })
 
 
       it('shows git modal correctly', function () {
       it('shows git modal correctly', function () {
-        const scope = mockScope({
-          project: {
-            owner: {
-              _id: '123',
-            },
-            features: {
-              gitBridge: true,
-            },
-          },
-        })
+        const scope = mockScope()
 
 
         cy.mount(
         cy.mount(
           <EditorProviders
           <EditorProviders
             scope={scope}
             scope={scope}
             layoutContext={{ leftMenuShown: true }}
             layoutContext={{ leftMenuShown: true }}
+            projectOwner={{
+              _id: '123' as UserId,
+              email: 'owner@example.com',
+              first_name: 'Test',
+              last_name: 'Owner',
+              privileges: 'owner',
+              signUpDate: new Date('2025-07-07').toISOString(),
+            }}
+            projectFeatures={
+              {
+                gitBridge: true,
+              } as any
+            }
           >
           >
             <EditorLeftMenu />
             <EditorLeftMenu />
           </EditorProviders>
           </EditorProviders>
@@ -262,21 +278,25 @@ describe('<EditorLeftMenu />', function () {
       })
       })
 
 
       it('shows git modal paywall correctly', function () {
       it('shows git modal paywall correctly', function () {
-        const scope = mockScope({
-          project: {
-            owner: {
-              _id: '123',
-            },
-            features: {
-              gitBridge: false,
-            },
-          },
-        })
+        const scope = mockScope()
 
 
         cy.mount(
         cy.mount(
           <EditorProviders
           <EditorProviders
             scope={scope}
             scope={scope}
             layoutContext={{ leftMenuShown: true }}
             layoutContext={{ leftMenuShown: true }}
+            projectOwner={{
+              _id: '123' as UserId,
+              email: 'owner@example.com',
+              first_name: 'Test',
+              last_name: 'Owner',
+              privileges: 'owner',
+              signUpDate: new Date('2025-07-07').toISOString(),
+            }}
+            projectFeatures={
+              {
+                gitBridge: false,
+              } as any
+            }
           >
           >
             <EditorLeftMenu />
             <EditorLeftMenu />
           </EditorProviders>
           </EditorProviders>

+ 2 - 1
services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx

@@ -9,6 +9,7 @@ import {
 } from '../../../../frontend/js/shared/context/layout-context'
 } from '../../../../frontend/js/shared/context/layout-context'
 import { FC, PropsWithChildren, useEffect } from 'react'
 import { FC, PropsWithChildren, useEffect } from 'react'
 import { useLocalCompileContext } from '@/shared/context/local-compile-context'
 import { useLocalCompileContext } from '@/shared/context/local-compile-context'
+import { ProjectCompiler } from '../../../../types/project-settings'
 
 
 const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
 const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
   localStorage.setItem(key, value)
   localStorage.setItem(key, value)
@@ -215,7 +216,7 @@ describe('<PdfPreview/>', function () {
         cached: false,
         cached: false,
         setup: () => {},
         setup: () => {},
         props: {
         props: {
-          compiler: 'lualatex',
+          compiler: 'lualatex' as ProjectCompiler,
         },
         },
       },
       },
       'ignores the compile from cache when draft mode changed': {
       'ignores the compile from cache when draft mode changed': {

+ 7 - 7
services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx

@@ -15,9 +15,9 @@ describe('<EditorCloneProjectModalWrapper />', function () {
     fetchMock.removeRoutes().clearHistory()
     fetchMock.removeRoutes().clearHistory()
   })
   })
 
 
-  const project = {
-    _id: 'project-1',
-    name: 'Test Project',
+  const contextProps = {
+    projectId: 'project-1',
+    projectName: 'Test Project',
   }
   }
 
 
   it('renders the translated modal title', async function () {
   it('renders the translated modal title', async function () {
@@ -30,7 +30,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         openProject={openProject}
         show
         show
       />,
       />,
-      { scope: { project } }
+      contextProps
     )
     )
 
 
     await screen.findByText('Copy project')
     await screen.findByText('Copy project')
@@ -55,7 +55,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         openProject={openProject}
         show
         show
       />,
       />,
-      { scope: { project } }
+      contextProps
     )
     )
 
 
     const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
     const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
@@ -123,7 +123,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         openProject={openProject}
         show
         show
       />,
       />,
-      { scope: { project } }
+      contextProps
     )
     )
 
 
     const button = await screen.findByRole('button', { name: 'Copy' })
     const button = await screen.findByRole('button', { name: 'Copy' })
@@ -160,7 +160,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         openProject={openProject}
         show
         show
       />,
       />,
-      { scope: { project } }
+      contextProps
     )
     )
 
 
     const button = await screen.findByRole('button', { name: 'Copy' })
     const button = await screen.findByRole('button', { name: 'Copy' })

+ 28 - 6
services/web/test/frontend/features/review-panel/review-panel.spec.tsx

@@ -1,12 +1,14 @@
 import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import {
 import {
   EditorProviders,
   EditorProviders,
+  makeProjectProvider,
   USER_EMAIL,
   USER_EMAIL,
   USER_ID,
   USER_ID,
 } from '../../helpers/editor-providers'
 } from '../../helpers/editor-providers'
 import { mockScope } from '../source-editor/helpers/mock-scope'
 import { mockScope } from '../source-editor/helpers/mock-scope'
 import { TestContainer } from '../source-editor/helpers/test-container'
 import { TestContainer } from '../source-editor/helpers/test-container'
 import { docId } from '../source-editor/helpers/mock-doc'
 import { docId } from '../source-editor/helpers/mock-doc'
+import { mockProject } from '../source-editor/helpers/mock-project'
 
 
 const userData = {
 const userData = {
   avatar_text: 'User',
   avatar_text: 'User',
@@ -181,14 +183,22 @@ describe('<ReviewPanel />', function () {
           removeChangeIds,
           removeChangeIds,
         },
         },
       },
       },
-      projectFeatures: { trackChangesVisible: true },
+    })
+    const project = mockProject({
+      projectOwner: {
+        _id: USER_ID,
+      },
+      projectFeatures: { trackChanges: false, trackChangesVisible: true },
     })
     })
 
 
     cy.wrap(scope).as('scope')
     cy.wrap(scope).as('scope')
 
 
     cy.mount(
     cy.mount(
       <TestContainer className="rp-size-expanded">
       <TestContainer className="rp-size-expanded">
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <CodeMirrorEditor />
           <CodeMirrorEditor />
         </EditorProviders>
         </EditorProviders>
       </TestContainer>
       </TestContainer>
@@ -653,6 +663,10 @@ describe('<ReviewPanel /> in mini mode', function () {
       projectFeatures: { trackChangesVisible: true },
       projectFeatures: { trackChangesVisible: true },
     })
     })
 
 
+    const project = mockProject({
+      projectFeatures: { trackChangesVisible: true },
+    })
+
     cy.intercept('GET', '/project/*/ranges', [
     cy.intercept('GET', '/project/*/ranges', [
       {
       {
         id: docId,
         id: docId,
@@ -672,7 +686,10 @@ describe('<ReviewPanel /> in mini mode', function () {
 
 
     cy.mount(
     cy.mount(
       <TestContainer>
       <TestContainer>
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <CodeMirrorEditor />
           <CodeMirrorEditor />
         </EditorProviders>
         </EditorProviders>
       </TestContainer>
       </TestContainer>
@@ -717,7 +734,7 @@ describe('<ReviewPanel /> in mini mode', function () {
     cy.get('.review-panel-mini').should('not.exist')
     cy.get('.review-panel-mini').should('not.exist')
   })
   })
 
 
-  it("doesn't render mini when a resolved comments is present in document", function () {
+  it("doesn't render mini when a resolved comment is present in document", function () {
     render({
     render({
       comments: [
       comments: [
         {
         {
@@ -747,7 +764,7 @@ describe('<ReviewPanel /> in mini mode', function () {
     cy.get('.review-panel-mini').should('not.exist')
     cy.get('.review-panel-mini').should('not.exist')
   })
   })
 
 
-  it('renders mini when a unresolved comments is present in document', function () {
+  it('renders mini when an unresolved comment is present in document', function () {
     render({
     render({
       comments: [
       comments: [
         {
         {
@@ -796,6 +813,8 @@ describe('<ReviewPanel /> for free users', function () {
   function mountEditor(ownerId = USER_ID) {
   function mountEditor(ownerId = USER_ID) {
     const scope = mockScope(undefined, {
     const scope = mockScope(undefined, {
       permissions: { write: true, trackedWrite: false, comment: true },
       permissions: { write: true, trackedWrite: false, comment: true },
+    })
+    const project = mockProject({
       projectFeatures: { trackChanges: false, trackChangesVisible: true },
       projectFeatures: { trackChanges: false, trackChangesVisible: true },
       projectOwner: {
       projectOwner: {
         _id: ownerId,
         _id: ownerId,
@@ -806,7 +825,10 @@ describe('<ReviewPanel /> for free users', function () {
 
 
     cy.mount(
     cy.mount(
       <TestContainer className="rp-size-expanded">
       <TestContainer className="rp-size-expanded">
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <CodeMirrorEditor />
           <CodeMirrorEditor />
         </EditorProviders>
         </EditorProviders>
       </TestContainer>
       </TestContainer>

+ 162 - 215
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx

@@ -1,18 +1,19 @@
 import { expect } from 'chai'
 import { expect } from 'chai'
 import sinon from 'sinon'
 import sinon from 'sinon'
-import { screen, fireEvent, render, waitFor } from '@testing-library/react'
+import { screen, fireEvent, waitFor } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
 import userEvent from '@testing-library/user-event'
 import userEvent from '@testing-library/user-event'
 
 
 import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
 import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
 import {
 import {
-  EditorProviders,
+  makeProjectProvider,
+  projectDefaults,
   USER_EMAIL,
   USER_EMAIL,
   USER_ID,
   USER_ID,
 } from '../../../helpers/editor-providers'
 } from '../../../helpers/editor-providers'
 import { location } from '@/shared/components/location'
 import { location } from '@/shared/components/location'
-import useScopeValue from '@/shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 
 
 async function changePrivilegeLevel(screen, { current, next }) {
 async function changePrivilegeLevel(screen, { current, next }) {
   const select = screen.getByDisplayValue(current)
   const select = screen.getByDisplayValue(current)
@@ -23,22 +24,25 @@ async function changePrivilegeLevel(screen, { current, next }) {
   fireEvent.click(option)
   fireEvent.click(option)
 }
 }
 
 
-describe('<ShareProjectModal/>', function () {
-  const project = {
-    _id: 'test-project',
-    name: 'Test Project',
-    features: {
-      collaborators: 10,
-      compileGroup: 'standard',
-    },
-    owner: {
-      _id: USER_ID,
-      email: USER_EMAIL,
-    },
-    members: [],
-    invites: [],
-  }
+const shareModalProjectDefaults = Object.assign({}, projectDefaults, {
+  _id: 'test-project',
+  name: 'Test Project',
+  features: {
+    collaborators: 10,
+    compileGroup: 'standard',
+  },
+  owner: {
+    _id: USER_ID,
+    email: USER_EMAIL,
+  },
+})
+
+function createContextProps(projectOverrides) {
+  const project = Object.assign({}, shareModalProjectDefaults, projectOverrides)
+  return { providers: { ProjectProvider: makeProjectProvider(project) } }
+}
 
 
+describe('<ShareProjectModal/>', function () {
   const contacts = [
   const contacts = [
     // user with edited name
     // user with edited name
     {
     {
@@ -101,9 +105,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('renders the modal', async function () {
   it('renders the modal', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
 
     await screen.findByText('Share Project')
     await screen.findByText('Share Project')
   })
   })
@@ -113,7 +118,7 @@ describe('<ShareProjectModal/>', function () {
 
 
     renderWithEditorContext(
     renderWithEditorContext(
       <ShareProjectModal {...modalProps} handleHide={handleHide} />,
       <ShareProjectModal {...modalProps} handleHide={handleHide} />,
-      { scope: { project } }
+      createContextProps()
     )
     )
 
 
     const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
     const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
@@ -128,9 +133,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles access level "private"', async function () {
   it('handles access level "private"', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project: { ...project, publicAccesLevel: 'private' } },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'private' })
+    )
 
 
     await screen.findByText('Link sharing is off')
     await screen.findByText('Link sharing is off')
     await screen.findByRole('button', { name: 'Turn on link sharing' })
     await screen.findByRole('button', { name: 'Turn on link sharing' })
@@ -149,10 +155,11 @@ describe('<ShareProjectModal/>', function () {
       readAndWriteHashPrefix: 'taEVki',
       readAndWriteHashPrefix: 'taEVki',
       readOnlyHashPrefix: 'j2xYbL',
       readOnlyHashPrefix: 'j2xYbL',
     }
     }
-    fetchMock.get(`/project/${project._id}/tokens`, tokens)
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
-    })
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, tokens)
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased' })
+    )
 
 
     await screen.findByText('Link sharing is on')
     await screen.findByText('Link sharing is on')
     await screen.findByRole('button', { name: 'Turn off link sharing' })
     await screen.findByRole('button', { name: 'Turn off link sharing' })
@@ -171,9 +178,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles legacy access level "readAndWrite"', async function () {
   it('handles legacy access level "readAndWrite"', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project: { ...project, publicAccesLevel: 'readAndWrite' } },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'readAndWrite' })
+    )
 
 
     await screen.findByText(
     await screen.findByText(
       'This project is public and can be edited by anyone with the URL.'
       'This project is public and can be edited by anyone with the URL.'
@@ -182,9 +190,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles legacy access level "readOnly"', async function () {
   it('handles legacy access level "readOnly"', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project: { ...project, publicAccesLevel: 'readOnly' } },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'readOnly' })
+    )
 
 
     await screen.findByText(
     await screen.findByText(
       'This project is public and can be viewed but not edited by anyone with the URL'
       'This project is public and can be viewed but not edited by anyone with the URL'
@@ -193,7 +202,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('displays actions for project-owners', async function () {
   it('displays actions for project-owners', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
 
     const invites = [
     const invites = [
       {
       {
@@ -204,18 +213,9 @@ describe('<ShareProjectModal/>', function () {
     ]
     ]
 
 
     // render as project owner: actions should be present
     // render as project owner: actions should be present
-    render(
-      <EditorProviders
-        scope={{
-          project: {
-            ...project,
-            invites,
-            publicAccesLevel: 'tokenBased',
-          },
-        }}
-      >
-        <ShareProjectModal {...modalProps} />
-      </EditorProviders>
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', invites })
     )
     )
 
 
     await screen.findByRole('button', { name: 'Turn off link sharing' })
     await screen.findByRole('button', { name: 'Turn off link sharing' })
@@ -231,23 +231,13 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    render(
-      <EditorProviders
-        scope={{
-          project: {
-            ...project,
-            invites,
-            publicAccesLevel: 'tokenBased',
-          },
-        }}
-        user={{
-          id: 'non-project-owner',
-          email: 'non-project-owner@example.com',
-        }}
-      >
-        <ShareProjectModal {...modalProps} />
-      </EditorProviders>
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      ...createContextProps({ publicAccessLevel: 'tokenBased', invites }),
+      user: {
+        id: 'non-project-owner',
+        email: 'non-project-owner@example.com',
+      },
+    })
 
 
     await screen.findByText(
     await screen.findByText(
       'To change access permissions, please ask the project owner'
       'To change access permissions, please ask the project owner'
@@ -269,23 +259,13 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    render(
-      <EditorProviders
-        scope={{
-          project: {
-            ...project,
-            invites,
-            publicAccesLevel: 'private',
-          },
-        }}
-        user={{
-          id: 'non-project-owner',
-          email: 'non-project-owner@example.com',
-        }}
-      >
-        <ShareProjectModal {...modalProps} />
-      </EditorProviders>
-    )
+    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      ...createContextProps({ publicAccessLevel: 'private', invites }),
+      user: {
+        id: 'non-project-owner',
+        email: 'non-project-owner@example.com',
+      },
+    })
 
 
     await screen.findByText(
     await screen.findByText(
       'To add more collaborators or turn on link sharing, please ask the project owner'
       'To add more collaborators or turn on link sharing, please ask the project owner'
@@ -300,11 +280,11 @@ describe('<ShareProjectModal/>', function () {
 
 
   it('only shows read-only token link to restricted token members', async function () {
   it('only shows read-only token link to restricted token members', async function () {
     window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
     window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
 
     renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
     renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      ...createContextProps({ publicAccessLevel: 'private' }),
       isRestrictedTokenMember: true,
       isRestrictedTokenMember: true,
-      scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
     })
     })
 
 
     // no buttons
     // no buttons
@@ -346,18 +326,12 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          members,
-          invites,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', members, invites })
+    )
 
 
     const projectOwnerEmail = USER_EMAIL
     const projectOwnerEmail = USER_EMAIL
 
 
@@ -381,7 +355,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('resends an invite', async function () {
   it('resends an invite', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.postOnce(
     fetchMock.postOnce(
       'express:/project/:projectId/invite/:inviteId/resend',
       'express:/project/:projectId/invite/:inviteId/resend',
       204
       204
@@ -395,15 +369,10 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          invites,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', invites })
+    )
 
 
     const [, closeButton] = screen.getAllByRole('button', {
     const [, closeButton] = screen.getAllByRole('button', {
       name: 'Close',
       name: 'Close',
@@ -419,7 +388,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('revokes an invite', async function () {
   it('revokes an invite', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
     fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
 
 
     const invites = [
     const invites = [
@@ -430,15 +399,10 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          invites,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', invites })
+    )
 
 
     const [, closeButton] = screen.getAllByRole('button', {
     const [, closeButton] = screen.getAllByRole('button', {
       name: 'Close',
       name: 'Close',
@@ -453,7 +417,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('changes member privileges to read + write', async function () {
   it('changes member privileges to read + write', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
     fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
 
 
     const members = [
     const members = [
@@ -464,17 +428,12 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          members,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', members })
+    )
 
 
-    const [, closeButton] = await screen.getAllByRole('button', {
+    const [, closeButton] = screen.getAllByRole('button', {
       name: 'Close',
       name: 'Close',
     })
     })
 
 
@@ -494,7 +453,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('removes a member from the project', async function () {
   it('removes a member from the project', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
     fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
 
 
     const members = [
     const members = [
@@ -505,15 +464,10 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          members,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', members })
+    )
 
 
     expect(
     expect(
       await screen.findAllByText('member-viewer@example.com')
       await screen.findAllByText('member-viewer@example.com')
@@ -537,7 +491,7 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('changes member privileges to owner with confirmation', async function () {
   it('changes member privileges to owner with confirmation', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
     fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
 
 
     const members = [
     const members = [
@@ -548,15 +502,10 @@ describe('<ShareProjectModal/>', function () {
       },
       },
     ]
     ]
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          members,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased', members })
+    )
 
 
     await screen.findByText('member-viewer@example.com')
     await screen.findByText('member-viewer@example.com')
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
@@ -586,16 +535,12 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('sends invites to input email addresses', async function () {
   it('sends invites to input email addresses', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'tokenBased' })
+    )
 
 
     const [inputElement] = await screen.findAllByLabelText('Add people')
     const [inputElement] = await screen.findAllByLabelText('Add people')
 
 
@@ -680,26 +625,24 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('displays a message when the collaborator limit is reached', async function () {
   it('displays a message when the collaborator limit is reached', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.post(
     fetchMock.post(
       '/event/paywall-prompt',
       '/event/paywall-prompt',
       {},
       {},
       { body: { 'paywall-type': 'project-sharing' } }
       { body: { 'paywall-type': 'project-sharing' } }
     )
     )
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          publicAccesLevel: 'tokenBased',
-          features: {
-            collaborators: 0,
-            compileGroup: 'standard',
-            trackChangesVisible: true,
-          },
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({
+        publicAccessLevel: 'tokenBased',
+        features: {
+          collaborators: 0,
+          compileGroup: 'standard',
+          trackChangesVisible: true,
         },
         },
-      },
-    })
+      })
+    )
 
 
     await screen.findByText('Add more collaborators')
     await screen.findByText('Add more collaborators')
 
 
@@ -719,24 +662,22 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('counts reviewers towards the collaborator limit', async function () {
   it('counts reviewers towards the collaborator limit', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          features: {
-            collaborators: 1,
-            trackChangesVisible: true,
-          },
-          members: [
-            {
-              _id: 'reviewer-id',
-              email: 'reviewer@example.com',
-              privileges: 'review',
-            },
-          ],
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({
+        features: {
+          collaborators: 1,
+          trackChangesVisible: true,
         },
         },
-      },
-    })
+        members: [
+          {
+            _id: 'reviewer-id',
+            email: 'reviewer@example.com',
+            privileges: 'review',
+          },
+        ],
+      })
+    )
 
 
     await screen.findByText('Add more collaborators')
     await screen.findByText('Add more collaborators')
 
 
@@ -757,16 +698,14 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles server error responses', async function () {
   it('handles server error responses', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
 
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: {
-        project: {
-          ...project,
-          publicAccesLevel: 'tokenBased',
-        },
-      },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({
+        publicAccessLevel: 'tokenBased',
+      })
+    )
 
 
     // loading contacts
     // loading contacts
     await waitFor(() => {
     await waitFor(() => {
@@ -818,21 +757,25 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('handles switching between access levels', async function () {
   it('handles switching between access levels', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.post('express:/project/:projectId/settings/admin', 204)
     fetchMock.post('express:/project/:projectId/settings/admin', 204)
 
 
     let setPublicAccessLevel = function () {}
     let setPublicAccessLevel = function () {}
 
 
     function WrappedModal() {
     function WrappedModal() {
-      setPublicAccessLevel = useScopeValue('project.publicAccesLevel')[1]
+      const { updateProject } = useProjectContext()
+      setPublicAccessLevel = publicAccessLevel => {
+        updateProject({ publicAccessLevel })
+      }
       return <ShareProjectModal {...modalProps} />
       return <ShareProjectModal {...modalProps} />
     }
     }
 
 
-    renderWithEditorContext(<WrappedModal />, {
-      scope: {
-        project: { ...project, publicAccesLevel: 'private' },
-      },
-    })
+    renderWithEditorContext(
+      <WrappedModal />,
+      createContextProps({
+        publicAccessLevel: 'private',
+      })
+    )
 
 
     await screen.findByText('Link sharing is off')
     await screen.findByText('Link sharing is off')
 
 
@@ -848,8 +791,8 @@ describe('<ShareProjectModal/>', function () {
     })
     })
 
 
     // NOTE: the project data is usually updated via the websocket connection
     // NOTE: the project data is usually updated via the websocket connection
-    // but we can't do that so we're doing it via the scope value store (this
-    // will be via the project context when this value has been migrated)
+    // but we can't do that so we're doing it via the project context, which is
+    // exposed in a hacky way here
     setPublicAccessLevel('tokenBased')
     setPublicAccessLevel('tokenBased')
 
 
     await screen.findByText('Link sharing is on')
     await screen.findByText('Link sharing is on')
@@ -870,9 +813,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('avoids selecting unmatched contact', async function () {
   it('avoids selecting unmatched contact', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
 
     const [inputElement] = await screen.findAllByLabelText('Add people')
     const [inputElement] = await screen.findAllByLabelText('Add people')
 
 
@@ -924,9 +868,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('selects contact by typing the entire email and blurring the input', async function () {
   it('selects contact by typing the entire email and blurring the input', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
 
     const [inputElement] = await screen.findAllByLabelText('Add people')
     const [inputElement] = await screen.findAllByLabelText('Add people')
 
 
@@ -959,9 +904,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('selects contact by typing a partial email and selecting the suggestion', async function () {
   it('selects contact by typing a partial email and selecting the suggestion', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
 
     const [inputElement] = await screen.findAllByLabelText('Add people')
     const [inputElement] = await screen.findAllByLabelText('Add people')
 
 
@@ -993,9 +939,10 @@ describe('<ShareProjectModal/>', function () {
   })
   })
 
 
   it('allows an email address to be selected, removed, then re-added', async function () {
   it('allows an email address to be selected, removed, then re-added', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
 
     const [inputElement] = await screen.findAllByLabelText('Add people')
     const [inputElement] = await screen.findAllByLabelText('Add people')
 
 

+ 0 - 3
services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx

@@ -64,7 +64,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
     ]
     ]
 
 
     const scope = mockScope()
     const scope = mockScope()
-    scope.project.rootFolder = rootFolder
 
 
     cy.mount(
     cy.mount(
       <TestContainer>
       <TestContainer>
@@ -446,7 +445,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
     ]
     ]
 
 
     const scope = mockScope()
     const scope = mockScope()
-    scope.project.rootFolder = rootFolder
 
 
     cy.mount(
     cy.mount(
       <TestContainer>
       <TestContainer>
@@ -910,7 +908,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
     ]
     ]
 
 
     const scope = mockScope()
     const scope = mockScope()
-    scope.project.rootFolder = rootFolder
 
 
     cy.mount(
     cy.mount(
       <TestContainer>
       <TestContainer>

+ 9 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx

@@ -2,12 +2,15 @@ import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/
 import {
 import {
   EditorProviders,
   EditorProviders,
   makeEditorPropertiesProvider,
   makeEditorPropertiesProvider,
+  makeProjectProvider,
+  USER_ID,
 } from '../../../helpers/editor-providers'
 } from '../../../helpers/editor-providers'
 import { mockScope, rootFolderId } from '../helpers/mock-scope'
 import { mockScope, rootFolderId } from '../helpers/mock-scope'
 import { FC } from 'react'
 import { FC } from 'react'
 import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { TestContainer } from '../helpers/test-container'
 import { TestContainer } from '../helpers/test-container'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
+import { mockProject } from '../helpers/mock-project'
 
 
 const clickToolbarButton = (text: string) => {
 const clickToolbarButton = (text: string) => {
   cy.findByLabelText(text).click()
   cy.findByLabelText(text).click()
@@ -44,6 +47,11 @@ describe('<FigureModal />', function () {
   function mount() {
   function mount() {
     const content = ''
     const content = ''
     const scope = mockScope(content)
     const scope = mockScope(content)
+    const project = mockProject({
+      projectOwner: {
+        _id: USER_ID,
+      },
+    })
 
 
     const FileTreePathProvider: FC<React.PropsWithChildren> = ({
     const FileTreePathProvider: FC<React.PropsWithChildren> = ({
       children,
       children,
@@ -69,6 +77,7 @@ describe('<FigureModal />', function () {
           scope={scope}
           scope={scope}
           providers={{
           providers={{
             FileTreePathProvider,
             FileTreePathProvider,
+            ProjectProvider: makeProjectProvider(project),
             EditorPropertiesProvider: makeEditorPropertiesProvider({
             EditorPropertiesProvider: makeEditorPropertiesProvider({
               showVisual: true,
               showVisual: true,
               showSymbolPalette: false,
               showSymbolPalette: false,

+ 10 - 3
services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx

@@ -1,9 +1,13 @@
 import { mockScope } from '../helpers/mock-scope'
 import { mockScope } from '../helpers/mock-scope'
-import { EditorProviders } from '../../../helpers/editor-providers'
+import {
+  EditorProviders,
+  makeProjectProvider,
+} from '../../../helpers/editor-providers'
 import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { TestContainer } from '../helpers/test-container'
 import { TestContainer } from '../helpers/test-container'
 import forEach from 'mocha-each'
 import forEach from 'mocha-each'
 import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
 import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
+import { mockProject } from '../helpers/mock-project'
 
 
 const languages = [
 const languages = [
   { code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
   { code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
@@ -125,11 +129,14 @@ forEach(Object.keys(suggestions)).describe(
       cy.interceptEvents()
       cy.interceptEvents()
 
 
       const scope = mockScope(content)
       const scope = mockScope(content)
-      scope.project.spellCheckLanguage = spellCheckLanguage
+      const project = mockProject({ spellCheckLanguage })
 
 
       cy.mount(
       cy.mount(
         <TestContainer>
         <TestContainer>
-          <EditorProviders scope={scope}>
+          <EditorProviders
+            scope={scope}
+            providers={{ ProjectProvider: makeProjectProvider(project) }}
+          >
             <CodeMirrorEditor />
             <CodeMirrorEditor />
           </EditorProviders>
           </EditorProviders>
         </TestContainer>
         </TestContainer>

+ 5 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx

@@ -1,19 +1,24 @@
 import {
 import {
   EditorProviders,
   EditorProviders,
   makeEditorPropertiesProvider,
   makeEditorPropertiesProvider,
+  makeProjectProvider,
 } from '../../../helpers/editor-providers'
 } from '../../../helpers/editor-providers'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { mockScope } from '../helpers/mock-scope'
 import { mockScope } from '../helpers/mock-scope'
 import { TestContainer } from '../helpers/test-container'
 import { TestContainer } from '../helpers/test-container'
+import { mockProject } from '../helpers/mock-project'
 
 
 const mountEditor = (content: string) => {
 const mountEditor = (content: string) => {
   const scope = mockScope(content)
   const scope = mockScope(content)
 
 
+  const project = mockProject()
+
   cy.mount(
   cy.mount(
     <TestContainer>
     <TestContainer>
       <EditorProviders
       <EditorProviders
         scope={scope}
         scope={scope}
         providers={{
         providers={{
+          ProjectProvider: makeProjectProvider(project),
           EditorPropertiesProvider: makeEditorPropertiesProvider({
           EditorPropertiesProvider: makeEditorPropertiesProvider({
             showVisual: true,
             showVisual: true,
             showSymbolPalette: false,
             showSymbolPalette: false,

+ 4 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx

@@ -1,20 +1,24 @@
 import {
 import {
   EditorProviders,
   EditorProviders,
   makeEditorPropertiesProvider,
   makeEditorPropertiesProvider,
+  makeProjectProvider,
 } from '../../../helpers/editor-providers'
 } from '../../../helpers/editor-providers'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { mockScope } from '../helpers/mock-scope'
 import { mockScope } from '../helpers/mock-scope'
 import { TestContainer } from '../helpers/test-container'
 import { TestContainer } from '../helpers/test-container'
 import { isMac } from '@/shared/utils/os'
 import { isMac } from '@/shared/utils/os'
+import { mockProject } from '../helpers/mock-project'
 
 
 const mountEditor = (content: string) => {
 const mountEditor = (content: string) => {
   const scope = mockScope(content)
   const scope = mockScope(content)
+  const project = mockProject()
 
 
   cy.mount(
   cy.mount(
     <TestContainer>
     <TestContainer>
       <EditorProviders
       <EditorProviders
         scope={scope}
         scope={scope}
         providers={{
         providers={{
+          ProjectProvider: makeProjectProvider(project),
           EditorPropertiesProvider: makeEditorPropertiesProvider({
           EditorPropertiesProvider: makeEditorPropertiesProvider({
             showVisual: true,
             showVisual: true,
             showSymbolPalette: false,
             showSymbolPalette: false,

+ 78 - 0
services/web/test/frontend/features/source-editor/helpers/mock-project.ts

@@ -0,0 +1,78 @@
+import { docId } from './mock-doc'
+import { Folder } from '../../../../../types/folder'
+import { UserId } from '../../../../../types/user'
+import { ProjectCompiler } from '../../../../../types/project-settings'
+
+export const rootFolderId = '012345678901234567890123'
+export const figuresFolderId = '123456789012345678901234'
+export const figureId = '234567890123456789012345'
+export const mockProject = ({
+  projectFeatures = {},
+  projectOwner = undefined,
+  spellCheckLanguage = 'en',
+  rootFolder = null,
+}: any = {}) => {
+  return {
+    _id: 'test-project',
+    name: 'Test Project',
+    spellCheckLanguage,
+    rootDocId: '_root_doc_id',
+    rootFolder:
+      rootFolder ||
+      ([
+        {
+          _id: rootFolderId,
+          name: 'rootFolder',
+          docs: [
+            {
+              _id: docId,
+              name: 'test.tex',
+            },
+          ],
+          folders: [
+            {
+              _id: figuresFolderId,
+              name: 'figures',
+              docs: [
+                {
+                  _id: 'fake-nested-doc-id',
+                  name: 'foo.tex',
+                },
+              ],
+              folders: [],
+              fileRefs: [
+                {
+                  _id: figureId,
+                  name: 'frog.jpg',
+                  hash: '42',
+                },
+                {
+                  _id: 'fake-figure-id',
+                  name: 'unicorn.png',
+                  hash: '43',
+                },
+              ],
+            },
+          ],
+          fileRefs: [],
+        },
+      ] as Folder[]),
+    features: {
+      trackChanges: true,
+      ...projectFeatures,
+    },
+    compiler: 'pdflatex' as ProjectCompiler,
+    imageName: 'texlive-full:2024.1',
+    trackChangesState: false,
+    invites: [],
+    members: [],
+    owner: projectOwner || {
+      _id: '124abd' as UserId,
+      email: 'owner@example.com',
+      first_name: 'Test',
+      last_name: 'Owner',
+      privileges: 'owner',
+      signUpDate: new Date('2025-07-07').toISOString(),
+    },
+  }
+}

+ 3 - 58
services/web/test/frontend/features/source-editor/helpers/mock-scope.ts

@@ -1,18 +1,10 @@
 import { docId, mockDoc } from './mock-doc'
 import { docId, mockDoc } from './mock-doc'
 import { sleep } from '../../../helpers/sleep'
 import { sleep } from '../../../helpers/sleep'
-import { Folder } from '../../../../../types/folder'
 
 
 export const rootFolderId = '012345678901234567890123'
 export const rootFolderId = '012345678901234567890123'
-export const figuresFolderId = '123456789012345678901234'
-export const figureId = '234567890123456789012345'
 export const mockScope = (
 export const mockScope = (
   content?: string,
   content?: string,
-  {
-    docOptions = {},
-    projectFeatures = {},
-    permissions = {},
-    projectOwner = undefined,
-  }: any = {}
+  { docOptions = {}, permissions = {} }: any = {}
 ) => {
 ) => {
   return {
   return {
     editor: {
     editor: {
@@ -21,55 +13,8 @@ export const mockScope = (
       currentDocumentId: docId,
       currentDocumentId: docId,
       wantTrackChanges: false,
       wantTrackChanges: false,
     },
     },
-    project: {
-      _id: 'test-project',
-      name: 'Test Project',
-      spellCheckLanguage: 'en',
-      rootFolder: [
-        {
-          _id: rootFolderId,
-          name: 'rootFolder',
-          docs: [
-            {
-              _id: docId,
-              name: 'test.tex',
-            },
-          ],
-          folders: [
-            {
-              _id: figuresFolderId,
-              name: 'figures',
-              docs: [
-                {
-                  _id: 'fake-nested-doc-id',
-                  name: 'foo.tex',
-                },
-              ],
-              folders: [],
-              fileRefs: [
-                {
-                  _id: figureId,
-                  name: 'frog.jpg',
-                  hash: '42',
-                },
-                {
-                  _id: 'fake-figure-id',
-                  name: 'unicorn.png',
-                  hash: '43',
-                },
-              ],
-            },
-          ],
-          fileRefs: [],
-        },
-      ] as Folder[],
-      features: {
-        trackChanges: true,
-        ...projectFeatures,
-      },
-      trackChangesState: {},
-      members: [],
-      owner: projectOwner,
+    pdf: {
+      logEntryAnnotations: {},
     },
     },
     permissions: {
     permissions: {
       comment: true,
       comment: true,

+ 107 - 34
services/web/test/frontend/helpers/editor-providers.tsx

@@ -23,10 +23,12 @@ import {
   EditorOpenDocContext,
   EditorOpenDocContext,
   type EditorOpenDocContextState,
   type EditorOpenDocContextState,
 } from '@/features/ide-react/context/editor-open-doc-context'
 } from '@/features/ide-react/context/editor-open-doc-context'
+import { ProjectContext } from '@/shared/context/project-context'
 import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
 import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import useDetachLayout from '@/shared/hooks/use-detach-layout'
 import useDetachLayout from '@/shared/hooks/use-detach-layout'
 import useExposedState from '@/shared/hooks/use-exposed-state'
 import useExposedState from '@/shared/hooks/use-exposed-state'
+import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
 import {
 import {
   EditorPropertiesContext,
   EditorPropertiesContext,
   EditorPropertiesContextValue,
   EditorPropertiesContextValue,
@@ -42,6 +44,12 @@ import type { PermissionsLevel } from '@/features/ide-react/types/permissions'
 import type { Folder } from '../../../types/folder'
 import type { Folder } from '../../../types/folder'
 import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state'
 import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state'
 import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
 import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
+import {
+  ProjectMetadata,
+  ProjectUpdate,
+} from '@/shared/context/types/project-metadata'
+import { UserId } from '../../../types/user'
+import { ProjectCompiler } from '../../../types/project-settings'
 
 
 // these constants can be imported in tests instead of
 // these constants can be imported in tests instead of
 // using magic strings
 // using magic strings
@@ -68,10 +76,11 @@ const defaultUserSettings = {
 export type EditorProvidersProps = {
 export type EditorProvidersProps = {
   user?: { id: string; email: string }
   user?: { id: string; email: string }
   projectId?: string
   projectId?: string
-  projectOwner?: { _id: string; email: string }
+  projectName?: string
+  projectOwner?: ProjectMetadata['owner']
   rootDocId?: string
   rootDocId?: string
   imageName?: string
   imageName?: string
-  compiler?: string
+  compiler?: ProjectCompiler
   socket?: Socket
   socket?: Socket
   isRestrictedTokenMember?: boolean
   isRestrictedTokenMember?: boolean
   scope?: Record<string, any>
   scope?: Record<string, any>
@@ -85,6 +94,46 @@ export type EditorProvidersProps = {
   providers?: Record<string, React.FC<React.PropsWithChildren<any>>>
   providers?: Record<string, React.FC<React.PropsWithChildren<any>>>
 }
 }
 
 
+export const projectDefaults = {
+  _id: PROJECT_ID,
+  name: PROJECT_NAME,
+  owner: {
+    _id: '124abd' as UserId,
+    email: 'owner@example.com',
+    first_name: 'Test',
+    last_name: 'Owner',
+    privileges: 'owner',
+    signUpDate: new Date('2025-07-07').toISOString(),
+  },
+  features: {
+    referencesSearch: true,
+    gitBridge: false,
+  },
+  rootDocId: '_root_doc_id',
+  rootFolder: [
+    {
+      _id: 'root-folder-id',
+      name: 'rootFolder',
+      docs: [
+        {
+          _id: '_root_doc_id',
+          name: 'main.tex',
+        },
+      ],
+      folders: [],
+      fileRefs: [],
+    },
+  ],
+  imageName: 'texlive-full:2024.1',
+  compiler: 'pdflatex' as ProjectCompiler,
+  members: [],
+  invites: [],
+}
+
+/**
+ * @typedef {import('@/shared/context/layout-context').LayoutContextValue} LayoutContextValue
+ * @type Partial<LayoutContextValue>
+ */
 const layoutContextDefault = {
 const layoutContextDefault = {
   view: 'editor',
   view: 'editor',
   openFile: null,
   openFile: null,
@@ -99,37 +148,23 @@ const layoutContextDefault = {
 
 
 export function EditorProviders({
 export function EditorProviders({
   user = { id: USER_ID, email: USER_EMAIL },
   user = { id: USER_ID, email: USER_EMAIL },
-  projectId = PROJECT_ID,
-  projectOwner = {
-    _id: '124abd',
-    email: 'owner@example.com',
-  },
-  rootDocId = '_root_doc_id',
-  imageName = 'texlive-full:2024.1',
-  compiler = 'pdflatex',
+  projectId = projectDefaults._id,
+  projectName = projectDefaults.name,
+  projectOwner = projectDefaults.owner,
+  rootDocId = projectDefaults.rootDocId,
+  imageName = projectDefaults.imageName,
+  compiler = projectDefaults.compiler,
   socket = new SocketIOMock() as any as Socket,
   socket = new SocketIOMock() as any as Socket,
   isRestrictedTokenMember = false,
   isRestrictedTokenMember = false,
   scope: defaultScope = {},
   scope: defaultScope = {},
   features = {
   features = {
     referencesSearch: true,
     referencesSearch: true,
+    gitBridge: false,
   },
   },
   projectFeatures = features,
   projectFeatures = features,
   permissionsLevel = 'owner',
   permissionsLevel = 'owner',
   children,
   children,
-  rootFolder = [
-    {
-      _id: 'root-folder-id',
-      name: 'rootFolder',
-      docs: [
-        {
-          _id: '_root_doc_id',
-          name: 'main.tex',
-        },
-      ],
-      folders: [],
-      fileRefs: [],
-    },
-  ],
+  rootFolder = projectDefaults.rootFolder,
   /** @type {Partial<LayoutContext>} */
   /** @type {Partial<LayoutContext>} */
   layoutContext = layoutContextDefault,
   layoutContext = layoutContextDefault,
   userSettings = {},
   userSettings = {},
@@ -166,21 +201,26 @@ export function EditorProviders({
         currentDocumentId: null,
         currentDocumentId: null,
         wantTrackChanges: false,
         wantTrackChanges: false,
       },
       },
-      project: {
-        _id: projectId,
-        name: PROJECT_NAME,
-        owner: projectOwner,
-        features: projectFeatures,
-        rootDocId,
-        rootFolder,
-        imageName,
-        compiler,
-      },
       permissionsLevel,
       permissionsLevel,
     },
     },
     defaultScope
     defaultScope
   )
   )
 
 
+  const project = {
+    _id: projectId,
+    name: projectName,
+    owner: projectOwner,
+    features: projectFeatures,
+    rootDocId,
+    rootFolder,
+    imageName,
+    compiler,
+    members: [],
+    invites: [],
+    trackChangesState: false,
+    spellCheckLanguage: 'en',
+  }
+
   // Add details for useUserContext
   // Add details for useUserContext
   window.metaAttributesCache.set('ol-user', { ...user, features })
   window.metaAttributesCache.set('ol-user', { ...user, features })
   window.metaAttributesCache.set('ol-project_id', projectId)
   window.metaAttributesCache.set('ol-project_id', projectId)
@@ -199,6 +239,7 @@ export function EditorProviders({
           wantTrackChanges: scope.editor.wantTrackChanges,
           wantTrackChanges: scope.editor.wantTrackChanges,
         }),
         }),
         LayoutProvider: makeLayoutProvider(layoutContext),
         LayoutProvider: makeLayoutProvider(layoutContext),
+        ProjectProvider: makeProjectProvider(project),
         ...providers,
         ...providers,
       }}
       }}
     >
     >
@@ -503,3 +544,35 @@ export function makeEditorPropertiesProvider(
 
 
   return EditorPropertiesProvider
   return EditorPropertiesProvider
 }
 }
+
+export function makeProjectProvider(initialProject: ProjectMetadata) {
+  const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
+    const [project, setProject] = useState(initialProject)
+
+    const updateProject = useCallback((projectUpdateData: ProjectUpdate) => {
+      setProject(projectData =>
+        Object.assign({}, projectData, projectUpdateData)
+      )
+    }, [])
+
+    const value = {
+      projectId: project._id,
+      project,
+      joinProject: () => {},
+      updateProject,
+      joinedOnce: true,
+      projectSnapshot: new ProjectSnapshot(project._id),
+      tags: [],
+      features: project.features,
+      name: project.name,
+    }
+
+    return (
+      <ProjectContext.Provider value={value}>
+        {children}
+      </ProjectContext.Provider>
+    )
+  }
+
+  return ProjectProvider
+}