Explorar o código

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 hai 1 ano
pai
achega
905cc5d45f
Modificáronse 79 ficheiros con 807 adicións e 704 borrados
  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()
   }
   const user = useUserContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext()
   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
     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
       return null
     } else {
@@ -28,8 +24,8 @@ const EditorCloneProjectModalWrapper = React.memo(
           handleHide={handleHide}
           show={show}
           handleAfterCloned={openProject}
-          projectId={projectId}
-          projectName={projectName}
+          projectId={project._id}
+          projectName={project.name}
           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() {
   const { t } = useTranslation()
   const { pdfDownloadUrl, pdfUrl } = useCompileContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   function sendDownloadEvent() {
     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() {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   function sendDownloadEvent() {
     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 { 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'
 
 export default function useProjectWideSettingsSocketListener() {
   const { socket } = useIdeContext()
 
-  const [project, setProject] = useScopeValue<ProjectSettings | undefined>(
-    'project'
-  )
+  const { project, updateProject } = useProjectContext()
 
   const setCompiler = useCallback(
     (compiler: ProjectSettings['compiler']) => {
       if (project) {
-        setProject({ ...project, compiler })
+        updateProject({ compiler })
       }
     },
-    [project, setProject]
+    [project, updateProject]
   )
 
   const setImageName = useCallback(
     (imageName: ProjectSettings['imageName']) => {
       if (project) {
-        setProject({ ...project, imageName })
+        updateProject({ imageName })
       }
     },
-    [project, setProject]
+    [project, updateProject]
   )
 
   const setSpellCheckLanguage = useCallback(
     (spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
       if (project) {
-        setProject({ ...project, spellCheckLanguage })
+        updateProject({ spellCheckLanguage })
       }
     },
-    [project, setProject]
+    [project, updateProject]
   )
 
   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 useScopeValue from '../../../shared/hooks/use-scope-value'
 import type { ProjectSettings } from '../utils/api'
 import useRootDocId from './use-root-doc-id'
 import useSaveProjectSettings from './use-save-project-settings'
 import useSetSpellCheckLanguage from './use-set-spell-check-language'
 import { debugConsole } from '@/utils/debugging'
+import { useProjectContext } from '@/shared/context/project-context'
 
 export default function useProjectWideSettings() {
   // The value will be undefined on mount
-  const [project] = useScopeValue<ProjectSettings | undefined>('project')
+  const { project } = useProjectContext()
   const saveProjectSettings = useSaveProjectSettings()
 
   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 { 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 useSaveProjectSettings from './use-save-project-settings'
 
 export default function useRootDocId() {
-  const [rootDocId] =
-    useScopeValue<ProjectSettings['rootDocId']>('project.rootDocId')
+  const { project } = useProjectContext()
+  const rootDocId = project?.rootDocId
   const { permissionsLevel } = useIdeReactContext()
   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 { 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() {
-  // 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 (
     key: keyof ProjectSettings,
     newSetting: ProjectSettings[keyof ProjectSettings]
   ) => {
-    if (projectSettings) {
-      const currentSetting = projectSettings[key]
+    if (project) {
+      const currentSetting = project[key]
       if (currentSetting !== newSetting) {
         await saveProjectSettings(projectId, {
           [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 useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useProjectContext } from '@/shared/context/project-context'
 import { type ProjectSettings, saveUserSettings } from '../utils/api'
 import useSaveProjectSettings from './use-save-project-settings'
 
 export default function useSetSpellCheckLanguage() {
-  const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue<
-    ProjectSettings['spellCheckLanguage']
-  >('project.spellCheckLanguage')
+  const { project } = useProjectContext()
+  const spellCheckLanguage = project?.spellCheckLanguage
   const saveProjectSettings = useSaveProjectSettings()
 
   return useCallback(
@@ -16,10 +15,8 @@ export default function useSetSpellCheckLanguage() {
         newSpellCheckLanguage !== spellCheckLanguage
 
       if (allowUpdate) {
-        setSpellCheckLanguage(newSpellCheckLanguage)
-
         // 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)
 
         // For user settings, we only need to save it on server-side,
@@ -27,6 +24,6 @@ export default function useSetSpellCheckLanguage() {
         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 { UserSettings } from '../../../../../types/user-settings'
 
-export type ProjectSettings = {
+export interface ProjectSettings {
   compiler: ProjectCompiler
   imageName: 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
 }) {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   // Don't allow overwriting files with a folder
   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,
 }: SelectProjectProps) {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   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 } =
     useFileTreeActionable()
   const { fileTreeData } = useFileTreeData()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   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
 export default function RedirectToLogin() {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
   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,
   } = useFileTreeActionable()
 
-  const { owner } = useProjectContext()
+  const { project } = useProjectContext()
+  const projectOwner = project?.owner?._id
 
   const downloadWithAnalytics = useCallback(() => {
     // we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
     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(() => {
     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] =
     useState<HTMLDivElement | null>(null)
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { fileTreeData } = useFileTreeData()
   const isReady = Boolean(projectId && fileTreeData)
   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> = ({
   children,
 }) => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { fileTreeReadOnly } = useFileTreeData()
   const { indexAllReferences } = useReferencesContext()
   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, children }) => {
-  const { _id: projectId, rootDocId } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const rootDocId = project?.rootDocId
 
   const [initialSelectedEntityId] = usePersistedState(
     `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) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { fileTreeReadOnly } = useFileTreeData()
   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
   onError: () => void
 }) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   return (
     <img
       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,
   file,
 }: FileViewRefreshButtonProps) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [refreshing, setRefreshing] = useState(false)
   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
   onError: () => void
 }) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   const [textPreview, setTextPreview] = useState('')
   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() {
   const { view } = useLayoutContext()
   const user = useUserContext()
-  const project = useProjectContext()
+  const { projectId, project, features } = useProjectContext()
   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 [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> = ({
   children,
 }) => {
-  const { rootDocId, owner } = useProjectContext()
+  const { project } = useProjectContext()
+  const rootDocId = project?.rootDocId
+  const projectOwner = project?.owner?._id
   const { eventEmitter, projectJoined } = useIdeReactContext()
   const { openDocWithId, openInitialDoc } = useEditorManagerContext()
   const { currentDocumentId } = useEditorOpenDocContext()
@@ -81,7 +83,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
         openDocWithId(selected.entity._id, { keepCurrentView: true })
         if (selected.entity.name.endsWith('.bib')) {
           sendMB('open-bib-file', {
-            projectOwner: owner._id,
+            projectOwner,
             isSampleFile: selected.entity.name === 'sample.bib',
             linkedFileProvider: null,
           })
@@ -96,7 +98,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
       if (openFile) {
         if (selected?.entity?.name?.endsWith('.bib')) {
           sendMB('open-bib-file', {
-            projectOwner: owner._id,
+            projectOwner,
             isSampleFile: false,
             linkedFileProvider: (selected.entity as FileRef).linkedFileData
               ?.provider,
@@ -105,7 +107,7 @@ export const FileTreeOpenProvider: FC<React.PropsWithChildren> = ({
         window.dispatchEvent(new CustomEvent('file-view:file-opened'))
       }
     },
-    [fileTreeReady, setOpenFile, openDocWithId, owner]
+    [fileTreeReady, setOpenFile, openDocWithId, projectOwner]
   )
 
   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 getMeta from '@/utils/meta'
 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()
 
@@ -44,10 +46,6 @@ function populateIdeReactScope(store: ReactScopeValueStore) {
   store.set('settings', {})
 }
 
-function populateProjectScope(store: ReactScopeValueStore) {
-  store.allowNonExistentPath('project', true)
-}
-
 function populatePdfScope(store: ReactScopeValueStore) {
   store.allowNonExistentPath('pdf', true)
 }
@@ -61,7 +59,6 @@ export function createReactScopeValueStore() {
   // initialization code together with the context and would only populate
   // necessary values in the store, but this is simpler for now
   populateIdeReactScope(scopeStore)
-  populateProjectScope(scopeStore)
   populatePdfScope(scopeStore)
 
   scopeStore.allowNonExistentPath('hasLintingError')
@@ -94,6 +91,8 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const [projectJoined, setProjectJoined] = useState(false)
 
   const { socket, getSocketDebuggingInfo } = useConnectionContext()
+  const { joinProject, project } = useProjectContext()
+  const spellCheckLanguage = project?.spellCheckLanguage
 
   const reportError = useCallback(
     (error: any, meta?: Record<string, any>) => {
@@ -105,7 +104,7 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
         performance_now: performance.now(),
         release,
         client_load: LOADED_AT,
-        spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'),
+        spellCheckLanguage,
         ...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
   useEffect(() => {
     function handleJoinProjectResponse({
-      project: { rootDoc_id: rootDocId, ..._project },
+      project: {
+        rootDoc_id: rootDocId,
+        publicAccesLevel: publicAccessLevel,
+        ..._project
+      },
       permissionsLevel,
     }: 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)
       // Make watchers update immediately
       scopeStore.flushUpdates()
@@ -142,21 +152,12 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
       setProjectJoined(true)
     }
 
-    function handleMainBibliographyDocUpdated(payload: string) {
-      scopeStore.set('project.mainBibliographyDoc_id', payload)
-    }
-
     socket.on('joinProjectResponse', handleJoinProjectResponse)
-    socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated)
 
     return () => {
       socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
-      socket.removeListener(
-        'mainBibliographyDocUpdated',
-        handleMainBibliographyDocUpdated
-      )
     }
-  }, [socket, eventEmitter, scopeStore])
+  }, [socket, eventEmitter, scopeStore, joinProject])
 
   const ide = useMemo(() => {
     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]
   )
 
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const storageKey = `file_outline.expanded.${projectId}`
 
   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 hasViewerPermissions = useViewerPermissions()
   const anonymous = getMeta('ol-anonymous')
-  const project = useProjectContext()
+  const { features } = useProjectContext()
 
   useEffect(() => {
     let activePermissionsMap
@@ -106,7 +106,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
       activePermissionsMap = linkSharingWarningPermissionsMap
     } else if (anonymous) {
       activePermissionsMap = anonymousPermissionsMap
-    } else if (!project.features.trackChanges) {
+    } else if (!features.trackChanges) {
       activePermissionsMap = noTrackChangesPermissionsMap
     } else {
       activePermissionsMap = permissionsMap
@@ -117,7 +117,7 @@ export const PermissionsProvider: React.FC<React.PropsWithChildren> = ({
     permissionsLevel,
     setPermissions,
     hasViewerPermissions,
-    project.features.trackChanges,
+    features.trackChanges,
   ])
 
   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.ModalsContextProvider>
         <Providers.ConnectionProvider>
-          <Providers.IdeReactProvider>
-            <Providers.UserProvider>
-              <Providers.UserSettingsProvider>
-                <Providers.ProjectProvider>
+          <Providers.ProjectProvider>
+            <Providers.IdeReactProvider>
+              <Providers.UserProvider>
+                <Providers.UserSettingsProvider>
                   <Providers.SnapshotProvider>
                     <Providers.DetachProvider>
                       <Providers.EditorPropertiesProvider>
@@ -128,10 +128,10 @@ export const ReactContextRoot: FC<
                       </Providers.EditorPropertiesProvider>
                     </Providers.DetachProvider>
                   </Providers.SnapshotProvider>
-                </Providers.ProjectProvider>
-              </Providers.UserSettingsProvider>
-            </Providers.UserProvider>
-          </Providers.IdeReactProvider>
+                </Providers.UserSettingsProvider>
+              </Providers.UserProvider>
+            </Providers.IdeReactProvider>
+          </Providers.ProjectProvider>
         </Providers.ConnectionProvider>
       </Providers.ModalsContextProvider>
     </Providers.SplitTestProvider>

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

@@ -55,7 +55,7 @@ export const SnapshotContext = createContext<
 >(undefined)
 
 export const SnapshotProvider: FC<React.PropsWithChildren> = ({ children }) => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [snapshotLoadingState, setSnapshotLoadingState] =
     useState<SnapshotLoadingState>('')
   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,
   listProjectMembers,
 } 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 { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useModalsContext } from '@/features/ide-react/context/modals-context'
@@ -16,11 +16,9 @@ import { useLocation } from '@/shared/hooks/use-location'
 function useSocketListeners() {
   const { t } = useTranslation()
   const { socket } = useConnectionContext()
-  const { permissionsLevel, projectId } = useIdeReactContext()
   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()
 
   useSocketListener(
@@ -51,10 +49,10 @@ function useSocketListeners() {
     useCallback(
       (data: { newAccessLevel?: PublicAccessLevel }) => {
         if (data.newAccessLevel) {
-          setPublicAccessLevel(data.newAccessLevel)
+          updateProject({ publicAccessLevel: data.newAccessLevel })
         }
       },
-      [setPublicAccessLevel]
+      [updateProject]
     )
   )
 
@@ -65,13 +63,13 @@ function useSocketListeners() {
       listProjectMembers(projectId)
         .then(({ members }) => {
           if (members) {
-            setProjectMembers(members)
+            updateProject({ members })
           }
         })
         .catch(err => {
           debugConsole.error('Error fetching members for project', err)
         })
-    }, [projectId, setProjectMembers])
+    }, [projectId, updateProject])
   )
 
   useSocketListener(
@@ -83,7 +81,7 @@ function useSocketListeners() {
           listProjectMembers(projectId)
             .then(({ members }) => {
               if (members) {
-                setProjectMembers(members)
+                updateProject({ members })
               }
             })
             .catch(err => {
@@ -95,7 +93,7 @@ function useSocketListeners() {
           listProjectInvites(projectId)
             .then(({ invites }) => {
               if (invites) {
-                setProjectInvites(invites)
+                updateProject({ invites })
               }
             })
             .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 = () => {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const sendDownloadEvent = useCallback(() => {
     sendMB('download-zip-button-click', {
       projectId,
@@ -44,7 +44,7 @@ export const DownloadProjectZip = () => {
 export const DownloadProjectPDF = () => {
   const { t } = useTranslation()
   const { pdfDownloadUrl, pdfUrl } = useCompileContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const sendDownloadEvent = useCallback(() => {
     sendMB('download-pdf-button-click', {
       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() {
   const { pdfDownloadUrl } = useCompileContext()
 
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   const { t } = useTranslation()
   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) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   const { setError, firstRenderDone, highlights, position, setPosition } =
     useCompileContext()

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

@@ -28,7 +28,8 @@ export default function useSynctex(): {
   syncToCodeInFlight: boolean
   canSyncToPdf: boolean
 } {
-  const { _id: projectId, rootDocId } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const rootDocId = project?.rootDocId
 
   const { clsiServerId, pdfFile, position, setShowLogs, setHighlights } =
     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 { permissionsLevel } = useIdeReactContext()
   const { write, trackedWrite } = usePermissionsContext()
-  const project = useProjectContext()
+  const { features } = useProjectContext()
   const [showUpgradeModal, setShowUpgradeModal] = useState(false)
   const showViewOption = permissionsLevel === 'readOnly'
   const view = useCodeMirrorViewContext()
@@ -100,7 +100,7 @@ function ReviewModeSwitcher() {
                 view.focus()
                 return
               }
-              if (!project.features.trackChanges) {
+              if (!features.trackChanges) {
                 setShowUpgradeModal(true)
               } else {
                 sendMB('editing-mode-change', {
@@ -212,7 +212,7 @@ const ModeSwitcherToggleButtonContent = forwardRef<
   })
 
   const user = useUserContext()
-  const project = useProjectContext()
+  const { features } = useProjectContext()
   const { reviewPanelOpen } = useLayoutContext()
   const { inactiveTutorials } = useEditorContext()
 
@@ -222,7 +222,7 @@ const ModeSwitcherToggleButtonContent = forwardRef<
   const canShowReviewModePromo =
     reviewPanelOpen &&
     currentMode !== 'review' &&
-    project.features.trackChanges &&
+    features.trackChanges &&
     user.signUpDate &&
     user.signUpDate < '2025-03-15' &&
     !hasCompletedReviewModeTutorial

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

@@ -26,7 +26,7 @@ function UpgradeTrackChangesModal({
   setShow,
 }: UpgradeTrackChangesModalProps) {
   const { t } = useTranslation()
-  const project = useProjectContext()
+  const { project } = useProjectContext()
   const user = useUserContext()
 
   return (
@@ -62,9 +62,9 @@ function UpgradeTrackChangesModal({
             </ul>
           </OLCol>
         </OLRow>
-        {project.owner && (
+        {Boolean(project?.owner) && (
           <div className="text-center">
-            {project.owner._id === user.id ? (
+            {project?.owner._id === user.id ? (
               user.allowedFreeTrial ? (
                 <OLButton
                   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> = ({
   children,
 }) => {
-  const { _id: projectId, members, owner } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const { members, owner } = project || {}
   const { isRestrictedTokenMember } = useEditorContext()
 
   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
   const value = useMemo(() => {
+    if (!owner || !members) {
+      return
+    }
     const value: ChangesUsers = new Map(changesUsers)
     value.set(owner._id, { ...owner, id: owner._id })
     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 }) => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { currentDocument } = useEditorOpenDocContext()
   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 { postJSON } from '@/infrastructure/fetch-json'
 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'
 
 export type TrackChangesState = {
@@ -48,14 +48,14 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
 }) => {
   const permissions = usePermissionsContext()
   const { socket } = useConnectionContext()
-  const project = useProjectContext()
+  const { projectId, project, features } = useProjectContext()
   const user = useUserContext()
   const { setWantTrackChanges } = useEditorPropertiesContext()
 
   // TODO: update project.trackChangesState instead?
   const [trackChangesValue, setTrackChangesValue] = useState<
-    ProjectContextValue['trackChangesState']
-  >(project.trackChangesState ?? false)
+    ProjectMetadata['trackChangesState']
+  >(project?.trackChangesState ?? false)
 
   useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue)
 
@@ -88,11 +88,11 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
 
   const saveTrackChanges = useCallback(
     async (trackChangesBody: SaveTrackChangesRequestBody) => {
-      postJSON(`/project/${project._id}/track_changes`, {
+      postJSON(`/project/${projectId}/track_changes`, {
         body: trackChangesBody,
       })
     },
-    [project._id]
+    [projectId]
   )
 
   const saveTrackChangesForCurrentUser = useCallback(
@@ -122,7 +122,7 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
     useCallback(() => {
       if (
         user.id &&
-        project.features.trackChanges &&
+        features.trackChanges &&
         permissions.write &&
         !onForEveryone
       ) {
@@ -139,7 +139,7 @@ export const TrackChangesStateProvider: FC<React.PropsWithChildren> = ({
       onForMembers,
       onForEveryone,
       permissions.write,
-      project.features.trackChanges,
+      features.trackChanges,
       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) {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [collapsedDocs, setCollapsedDocs] = usePersistedState<
     Record<DocId, boolean>,
     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'
 
 export default function useProjectRanges() {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const [error, setError] = useState<Error>()
   const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
   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 { 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(
     () => (members || []).map(member => member.email).sort(),
@@ -82,9 +83,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
       let data
 
       try {
-        const invite = (invites || []).find(
-          invite => invite.email === normalisedEmail
-        )
+        const invite = invites?.find(invite => invite.email === normalisedEmail)
 
         if (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
           successful_invite: !!data.invite,
           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,
           previousEditorsAmount,
           previousReviewersAmount,
@@ -144,15 +143,15 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
         setInFlight(false)
       } else if (data.invite) {
         updateProject({
-          invites: invites.concat(data.invite),
+          invites: invites?.concat(data.invite) || [data.invite],
         })
       } else if (data.users) {
         updateProject({
-          members: members.concat(data.users),
+          members: members?.concat(data.users) || data.users,
         })
       } else if (data.user) {
         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 { sendMB } from '@/infrastructure/event-tracking'
 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 { linkSharingEnforcementDate } from '../utils/link-sharing'
 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 EditMemberProps = {
-  member: ProjectContextMember
+  member: ProjectMember
   hasExceededCollaboratorLimit: boolean
   hasBeenDowngraded: boolean
   canAddCollaborators: boolean
@@ -51,8 +51,9 @@ export default function EditMember({
     setPrivileges(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()
 
   // Immediately commit this change if it's lower impact (eg. editor > viewer)
@@ -88,14 +89,15 @@ export default function EditMember({
     } else if (newPrivileges === 'removeAccess') {
       monitorRequest(() => removeMemberFromProject(projectId, member)).then(
         () => {
-          const updatedMembers = members.filter(existing => existing !== member)
+          const updatedMembers =
+            members?.filter(existing => existing !== member) || []
           updateProject({
             members: updatedMembers,
           })
           sendMB('collaborator-removed', {
             project_id: projectId,
             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(() => {
         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 { permissionsLevel } = useIdeReactContext()
-  const { members, features, _id: projectId } = useProjectContext()
+  const { project, features, projectId } = useProjectContext()
+  const members = project?.members
 
   const handleHide = () => {
     setShow(false)
@@ -24,7 +25,12 @@ const EditorOverLimitModal = () => {
   useEffect(() => {
     const showModalCooldownHours = 24
     const hasExceededCollaboratorLimit = () => {
-      if (isProjectOwner || !features || permissionsLevel === 'readOnly') {
+      if (
+        isProjectOwner ||
+        !features ||
+        !members ||
+        permissionsLevel === 'readOnly'
+      ) {
         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 OLButton from '@/features/ui/components/ol/ol-button'
 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({
   invite,
   isProjectOwner,
 }: {
-  invite: ProjectContextMember
+  invite: ProjectMember
   isProjectOwner: boolean
 }) {
   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 { monitorRequest, setError, inFlight } = useShareProjectContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   // 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 { 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) {
     event.preventDefault()
 
     monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
-      const updatedInvites = invites.filter(existing => existing !== invite)
+      const updatedInvites =
+        invites?.filter(existing => existing !== invite) || []
       updateProject({
         invites: updatedInvites,
       })
       sendMB('collaborator-invite-revoked', {
         project_id: projectId,
         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 { _id: projectId, publicAccessLevel } = useProjectContext()
+  const { projectId, project } = useProjectContext()
+  const { publicAccessLevel } = project || {}
 
   // set the access level of a project
   const setAccessLevel = useCallback(
@@ -145,7 +146,7 @@ function TokenBasedSharing({
   showLinks: boolean
 }) {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   const [tokens, setTokens] = useState<Tokens | null>(null)
 
@@ -244,7 +245,7 @@ function LegacySharing({
 
 export function ReadOnlyTokenLink() {
   const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   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'
 
 export default function MemberPrivileges({
   privileges,
 }: {
-  privileges: ProjectContextMember['privileges']
+  privileges: ProjectMember['privileges']
 }) {
   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() {
   const { t } = useTranslation()
-  const { owner } = useProjectContext()
+  const { project } = useProjectContext()
 
   return (
     <OLRow className="project-member">
       <OLCol xs={8}>
         <div className="project-member-email-icon">
           <MaterialIcon type="person" />
-          <div className="email-warning">{owner?.email}</div>
+          <div className="email-warning">{project?.owner.email}</div>
         </div>
       </OLCol>
       <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'
 
 export default function SendInvitesNotice() {
-  const { publicAccessLevel } = useProjectContext()
+  const { project } = useProjectContext()
+  const { publicAccessLevel } = project || {}
   const { isPendingEditor } = useEditorContext()
   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'
 
 export default function ShareModalBody() {
-  const { members, invites, features } = useProjectContext()
+  const { project, features } = useProjectContext()
+  const { members, invites } = project || {}
   const { isProjectOwner } = useEditorContext()
 
   // whether the project has not reached the collaborator limit
@@ -26,12 +27,12 @@ export default function ShareModalBody() {
       return true
     }
 
-    const editorInvites = invites.filter(
-      invite => invite.privileges !== 'readOnly'
-    ).length
+    const editorInvites =
+      invites?.filter(invite => invite.privileges !== 'readOnly').length || 0
 
     return (
-      members.filter(member => member.privileges !== 'readOnly').length +
+      (members?.filter(member => member.privileges !== 'readOnly').length ||
+        0) +
         editorInvites <
       (features.collaborators ?? 1)
     )
@@ -40,11 +41,11 @@ export default function ShareModalBody() {
   // determine if some but not all pending editors' permissions have been resolved,
   // for moving between warning and info notification states etc.
   const somePendingEditorsResolved = useMemo(() => {
-    return (
-      members.some(member =>
+    return Boolean(
+      members?.some(member =>
         ['readAndWrite', 'review'].includes(member.privileges)
       ) &&
-      members.some(member => member.pendingEditor || member.pendingReviewer)
+        members?.some(member => member.pendingEditor || member.pendingReviewer)
     )
   }, [members])
 
@@ -56,13 +57,14 @@ export default function ShareModalBody() {
     if (features.collaborators === -1) {
       return false
     }
-    return members.some(
-      member => member.pendingEditor || member.pendingReviewer
+    return (
+      members?.some(member => member.pendingEditor || member.pendingReviewer) ||
+      false
     )
   }, [features, isProjectOwner, members])
 
   const hasExceededCollaboratorLimit = useMemo(() => {
-    if (!isProjectOwner || !features) {
+    if (!isProjectOwner || !features || !members) {
       return false
     }
 
@@ -77,6 +79,9 @@ export default function ShareModalBody() {
   }, [features, isProjectOwner, members])
 
   const sortedMembers = useMemo(() => {
+    if (!members) {
+      return []
+    }
     return [
       ...members.filter(member => member.privileges === 'readAndWrite'),
       ...members.filter(member => member.pendingEditor),
@@ -126,7 +131,7 @@ export default function ShareModalBody() {
         )
       )}
 
-      {invites.map(invite => (
+      {(invites || []).map(invite => (
         <Invite
           key={invite._id}
           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 { useSplitTestContext } from '@/shared/context/split-test-context'
 import { sendMB } from '@/infrastructure/event-tracking'
-import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
 import { useEditorContext } from '@/shared/context/editor-context'
 import customLocalStorage from '@/infrastructure/local-storage'
 
 type ShareProjectContextValue = {
-  updateProject: (project: ProjectContextUpdateValue) => void
   monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
   inFlight: boolean
   setInFlight: React.Dispatch<
@@ -61,7 +59,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     useState<ShareProjectContextValue['inFlight']>(false)
   const [error, setError] = useState<ShareProjectContextValue['error']>()
 
-  const project = useProjectContext()
+  const { project, projectId } = useProjectContext()
   const { isProjectOwner } = useEditorContext()
 
   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)
   useEffect(() => {
     const hasExceededCollaboratorLimit = () => {
-      if (!isProjectOwner || !project.features) {
+      if (!isProjectOwner || !project || !project.features) {
         return false
       }
 
@@ -88,7 +86,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     }
 
     if (hasExceededCollaboratorLimit()) {
-      const localStorageKey = `last-shown-share-modal.${project._id}`
+      const localStorageKey = `last-shown-share-modal.${projectId}`
       const lastShownShareModalTime =
         customLocalStorage.getItem(localStorageKey)
       if (
@@ -99,17 +97,17 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
         customLocalStorage.setItem(localStorageKey, Date.now())
       }
     }
-  }, [project, isProjectOwner, handleOpen])
+  }, [project, isProjectOwner, handleOpen, projectId])
 
   // send tracking event when the modal is opened
   useEffect(() => {
     if (show) {
       sendMB('share-modal-opened', {
         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
   useEffect(() => {
@@ -147,12 +145,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     return promise
   }, [])
 
-  // merge the new data with the old project data
-  const updateProject = useCallback(
-    (data: ProjectContextUpdateValue) => Object.assign(project, data),
-    [project]
-  )
-
   if (!project) {
     return null
   }
@@ -160,7 +152,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   return (
     <ShareProjectContext.Provider
       value={{
-        updateProject,
         monitorRequest,
         inFlight,
         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 OLButton from '@/features/ui/components/ol/ol-button'
 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({
   member,
   cancel,
 }: {
-  member: ProjectContextMember
+  member: ProjectMember
   cancel: () => void
 }) {
   const { t } = useTranslation()
@@ -27,7 +27,7 @@ export default function TransferOwnershipModal({
   const [error, setError] = useState(false)
   const location = useLocation()
 
-  const { _id: projectId, name: projectName } = useProjectContext()
+  const { projectId, name: projectName } = useProjectContext()
 
   function confirm() {
     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 OLCol from '@/features/ui/components/ol/ol-col'
 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 (
     <OLRow className="project-member">
       <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 { permissionsLevel } = useIdeReactContext()
-  const { members, features, _id: projectId } = useProjectContext()
+  const { features, projectId, project } = useProjectContext()
+  const members = project?.members
 
   const handleHide = () => {
     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 view = useCodeMirrorViewContext()
   const { dispatch } = useFigureModalContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { loading: projectsLoading, data: projects, error } = useUserProjects()
   const [selectedProject, setSelectedProject] = useState<null | Project>(null)
   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 view = useCodeMirrorViewContext()
   const { dispatch, pastedImageData } = useFigureModalContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { rootFile } = useCurrentProjectFolders()
   const [folder, setFolder] = useState<File | null>(null)
   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 [nameDirty, setNameDirty] = useState<boolean>(false)
   const [name, setName] = useState<string>('')
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { rootFile } = useCurrentProjectFolders()
   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 { EditorState } from '@codemirror/state'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
 import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
 import useEventListener from '../../../shared/hooks/use-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 { useOnlineUsersContext } from '@/features/ide-react/context/online-users-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 { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
 import { SearchQuery } from '@codemirror/search'
@@ -92,17 +92,13 @@ function useCodeMirrorScope(view: EditorView) {
 
   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
   if (!permissions.write && !permissions.trackedWrite) {
     spellCheckLanguage = ''
   }
 
-  const [projectFeatures] =
-    useScopeValue<Record<string, boolean | string | number | undefined>>(
-      'project.features'
-    )
-
   const hunspellManager = useHunspell(spellCheckLanguage)
 
   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 [error, setError] = useState(false)
   const [data, setData] = useState<WordCountData | null>(null)
-  const { projectSnapshot, rootDocId } = useProjectContext()
+  const { projectSnapshot, project } = useProjectContext()
+  const rootDocId = project?.rootDocId
   const { spellCheckLanguage } = useProjectSettingsContext()
   const { openDocs } = useEditorManagerContext()
   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'
 
 export const WordCountServer: FC = () => {
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
   const { clsiServerId } = useLocalCompileContext()
 
   const [loading, setLoading] = useState(true)

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

@@ -9,9 +9,7 @@ import {
   useMemo,
   useState,
 } from 'react'
-import useScopeValue from '../hooks/use-scope-value'
 import useBrowserWindow from '../hooks/use-browser-window'
-import { useIdeContext } from './ide-context'
 import { useProjectContext } from './project-context'
 import { useDetachContext } from './detach-context'
 import getMeta from '../../utils/meta'
@@ -46,12 +44,18 @@ export const EditorContext = createContext<
 >(undefined)
 
 export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
-  const { socket } = useIdeContext()
   const { id: userId, featureUsage } = useUserContext()
   const { role } = useDetachContext()
   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 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(
     () => getMeta('ol-inactiveTutorials') || []
   )
@@ -95,10 +97,12 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
 
   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]
   )
@@ -110,33 +114,25 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
     [inactiveTutorials]
   )
 
-  useEffect(() => {
-    if (socket) {
-      socket.on('projectNameUpdated', setProjectName)
-      return () => socket.removeListener('projectNameUpdated', setProjectName)
-    }
-  }, [socket, setProjectName])
-
   const renameProject = useCallback(
     (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()

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

@@ -8,7 +8,6 @@ import {
   FC,
   useEffect,
 } from 'react'
-import useScopeValue from '../hooks/use-scope-value'
 import {
   renameInTree,
   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 { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
 import { Folder } from '../../../../types/folder'
-import { Project } from '../../../../types/project'
 import { MainDocument } from '../../../../types/project-settings'
 import { FindResult } from '@/features/file-tree/util/path'
 import {
@@ -28,6 +26,7 @@ import {
   useSnapshotContext,
 } from '@/features/ide-react/context/snapshot-context'
 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'
 
 const { buildFileTree, createFolder } =
@@ -182,7 +181,7 @@ export function useFileTreeData() {
 export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
   children,
 }) => {
-  const [project] = useScopeValue<Project>('project')
+  const { project } = useProjectContext()
   const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext()
   const { permissionsLevel } = useIdeReactContext()
   const { fileTreeFromHistory, snapshot, snapshotVersion } =
@@ -195,7 +194,7 @@ export const FileTreeDataProvider: FC<React.PropsWithChildren> = ({
   useEffect(() => {
     if (fileTreeFromHistory) return
     setRootFolder(project?.rootFolder)
-  }, [project, fileTreeFromHistory])
+  }, [project?.rootFolder, fileTreeFromHistory])
 
   useEffect(() => {
     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 { 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()
 

+ 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 { ProjectContextValue } from './types/project-context'
+import { ProjectUpdate, ProjectMetadata } from './types/project-metadata'
 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() {
   const context = useContext(ProjectContext)
@@ -18,35 +39,33 @@ export function useProjectContext() {
   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 }) => {
-  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(
     () =>
@@ -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,
-  ])
+    projectSnapshot,
+    tags,
+    features,
+    name,
+  }
 
   return (
     <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 { 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
   privileges: 'readOnly' | 'readAndWrite' | 'review'
   email: string
@@ -13,15 +13,11 @@ export type ProjectContextMember = {
   pendingReviewer?: boolean
 }
 
-export type ProjectContextValue = {
+export interface ProjectMetadata extends ProjectSettings {
   _id: string
-  name: string
-  rootDocId?: string
   mainBibliographyDocId?: string
-  compiler: string
-  imageName: string
-  members: ProjectContextMember[]
-  invites: ProjectContextMember[]
+  members: ProjectMember[]
+  invites: ProjectMember[]
   features: {
     collaborators?: number
     compileGroup?: 'alpha' | 'standard' | 'priority'
@@ -44,10 +40,8 @@ export type ProjectContextValue = {
     privileges: string
     signUpDate: string
   }
-  tags: Tag[]
+  rootFolder?: Folder[]
   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 = {}) {
   const { eventSource } = opts
   const { stopOnFirstError, setStopOnFirstError } = useCompileContext()
-  const { _id: projectId } = useProjectContext()
+  const { projectId } = useProjectContext()
 
   type Opts = {
     projectId: string

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

@@ -4,11 +4,16 @@ import {
   OverallThemeMeta,
   SpellCheckLanguage,
 } from '../../../../types/project-settings'
-import { EditorProviders } from '../../helpers/editor-providers'
+import {
+  EditorProviders,
+  makeProjectProvider,
+} from '../../helpers/editor-providers'
 import { mockScope } from './scope'
 import { Folder } from '../../../../types/folder'
 import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
 import getMeta from '@/utils/meta'
+import { mockProject } from '../../features/source-editor/helpers/mock-project'
+import { UserId } from '../../../../types/user'
 
 describe('<EditorLeftMenu />', function () {
   beforeEach(function () {
@@ -57,9 +62,14 @@ describe('<EditorLeftMenu />', function () {
 
     it('render full menu', function () {
       const scope = mockScope()
+      const project = mockProject()
 
       cy.mount(
-        <EditorProviders scope={scope} layoutContext={{ leftMenuShown: true }}>
+        <EditorProviders
+          scope={scope}
+          layoutContext={{ leftMenuShown: true }}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <EditorLeftMenu />
         </EditorProviders>
       )
@@ -209,12 +219,6 @@ describe('<EditorLeftMenu />', function () {
         })
 
         const scope = mockScope({
-          project: {
-            members: [],
-            owner: {
-              _id: '123',
-            },
-          },
           user: {
             features: {
               dropbox: false,
@@ -226,6 +230,14 @@ describe('<EditorLeftMenu />', function () {
           <EditorProviders
             scope={scope}
             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 />
           </EditorProviders>
@@ -236,21 +248,25 @@ describe('<EditorLeftMenu />', function () {
       })
 
       it('shows git modal correctly', function () {
-        const scope = mockScope({
-          project: {
-            owner: {
-              _id: '123',
-            },
-            features: {
-              gitBridge: true,
-            },
-          },
-        })
+        const scope = mockScope()
 
         cy.mount(
           <EditorProviders
             scope={scope}
             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 />
           </EditorProviders>
@@ -262,21 +278,25 @@ describe('<EditorLeftMenu />', function () {
       })
 
       it('shows git modal paywall correctly', function () {
-        const scope = mockScope({
-          project: {
-            owner: {
-              _id: '123',
-            },
-            features: {
-              gitBridge: false,
-            },
-          },
-        })
+        const scope = mockScope()
 
         cy.mount(
           <EditorProviders
             scope={scope}
             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 />
           </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'
 import { FC, PropsWithChildren, useEffect } from 'react'
 import { useLocalCompileContext } from '@/shared/context/local-compile-context'
+import { ProjectCompiler } from '../../../../types/project-settings'
 
 const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
   localStorage.setItem(key, value)
@@ -215,7 +216,7 @@ describe('<PdfPreview/>', function () {
         cached: false,
         setup: () => {},
         props: {
-          compiler: 'lualatex',
+          compiler: 'lualatex' as ProjectCompiler,
         },
       },
       '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()
   })
 
-  const project = {
-    _id: 'project-1',
-    name: 'Test Project',
+  const contextProps = {
+    projectId: 'project-1',
+    projectName: 'Test Project',
   }
 
   it('renders the translated modal title', async function () {
@@ -30,7 +30,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         show
       />,
-      { scope: { project } }
+      contextProps
     )
 
     await screen.findByText('Copy project')
@@ -55,7 +55,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         show
       />,
-      { scope: { project } }
+      contextProps
     )
 
     const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
@@ -123,7 +123,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         show
       />,
-      { scope: { project } }
+      contextProps
     )
 
     const button = await screen.findByRole('button', { name: 'Copy' })
@@ -160,7 +160,7 @@ describe('<EditorCloneProjectModalWrapper />', function () {
         openProject={openProject}
         show
       />,
-      { scope: { project } }
+      contextProps
     )
 
     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 {
   EditorProviders,
+  makeProjectProvider,
   USER_EMAIL,
   USER_ID,
 } from '../../helpers/editor-providers'
 import { mockScope } from '../source-editor/helpers/mock-scope'
 import { TestContainer } from '../source-editor/helpers/test-container'
 import { docId } from '../source-editor/helpers/mock-doc'
+import { mockProject } from '../source-editor/helpers/mock-project'
 
 const userData = {
   avatar_text: 'User',
@@ -181,14 +183,22 @@ describe('<ReviewPanel />', function () {
           removeChangeIds,
         },
       },
-      projectFeatures: { trackChangesVisible: true },
+    })
+    const project = mockProject({
+      projectOwner: {
+        _id: USER_ID,
+      },
+      projectFeatures: { trackChanges: false, trackChangesVisible: true },
     })
 
     cy.wrap(scope).as('scope')
 
     cy.mount(
       <TestContainer className="rp-size-expanded">
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <CodeMirrorEditor />
         </EditorProviders>
       </TestContainer>
@@ -653,6 +663,10 @@ describe('<ReviewPanel /> in mini mode', function () {
       projectFeatures: { trackChangesVisible: true },
     })
 
+    const project = mockProject({
+      projectFeatures: { trackChangesVisible: true },
+    })
+
     cy.intercept('GET', '/project/*/ranges', [
       {
         id: docId,
@@ -672,7 +686,10 @@ describe('<ReviewPanel /> in mini mode', function () {
 
     cy.mount(
       <TestContainer>
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <CodeMirrorEditor />
         </EditorProviders>
       </TestContainer>
@@ -717,7 +734,7 @@ describe('<ReviewPanel /> in mini mode', function () {
     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({
       comments: [
         {
@@ -747,7 +764,7 @@ describe('<ReviewPanel /> in mini mode', function () {
     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({
       comments: [
         {
@@ -796,6 +813,8 @@ describe('<ReviewPanel /> for free users', function () {
   function mountEditor(ownerId = USER_ID) {
     const scope = mockScope(undefined, {
       permissions: { write: true, trackedWrite: false, comment: true },
+    })
+    const project = mockProject({
       projectFeatures: { trackChanges: false, trackChangesVisible: true },
       projectOwner: {
         _id: ownerId,
@@ -806,7 +825,10 @@ describe('<ReviewPanel /> for free users', function () {
 
     cy.mount(
       <TestContainer className="rp-size-expanded">
-        <EditorProviders scope={scope}>
+        <EditorProviders
+          scope={scope}
+          providers={{ ProjectProvider: makeProjectProvider(project) }}
+        >
           <CodeMirrorEditor />
         </EditorProviders>
       </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 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 userEvent from '@testing-library/user-event'
 
 import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
 import {
-  EditorProviders,
+  makeProjectProvider,
+  projectDefaults,
   USER_EMAIL,
   USER_ID,
 } from '../../../helpers/editor-providers'
 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 }) {
   const select = screen.getByDisplayValue(current)
@@ -23,22 +24,25 @@ async function changePrivilegeLevel(screen, { current, next }) {
   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 = [
     // user with edited name
     {
@@ -101,9 +105,10 @@ describe('<ShareProjectModal/>', function () {
   })
 
   it('renders the modal', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
     await screen.findByText('Share Project')
   })
@@ -113,7 +118,7 @@ describe('<ShareProjectModal/>', function () {
 
     renderWithEditorContext(
       <ShareProjectModal {...modalProps} handleHide={handleHide} />,
-      { scope: { project } }
+      createContextProps()
     )
 
     const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
@@ -128,9 +133,10 @@ describe('<ShareProjectModal/>', 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.findByRole('button', { name: 'Turn on link sharing' })
@@ -149,10 +155,11 @@ describe('<ShareProjectModal/>', function () {
       readAndWriteHashPrefix: 'taEVki',
       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.findByRole('button', { name: 'Turn off link sharing' })
@@ -171,9 +178,10 @@ describe('<ShareProjectModal/>', 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(
       '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 () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project: { ...project, publicAccesLevel: 'readOnly' } },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps({ publicAccessLevel: 'readOnly' })
+    )
 
     await screen.findByText(
       '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 () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
     const invites = [
       {
@@ -204,18 +213,9 @@ describe('<ShareProjectModal/>', function () {
     ]
 
     // 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' })
@@ -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(
       '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(
       '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 () {
     window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
 
     renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
+      ...createContextProps({ publicAccessLevel: 'private' }),
       isRestrictedTokenMember: true,
-      scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
     })
 
     // 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
 
@@ -381,7 +355,7 @@ describe('<ShareProjectModal/>', function () {
   })
 
   it('resends an invite', async function () {
-    fetchMock.get(`/project/${project._id}/tokens`, {})
+    fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
     fetchMock.postOnce(
       'express:/project/:projectId/invite/:inviteId/resend',
       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', {
       name: 'Close',
@@ -419,7 +388,7 @@ describe('<ShareProjectModal/>', 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)
 
     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', {
       name: 'Close',
@@ -453,7 +417,7 @@ describe('<ShareProjectModal/>', 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)
 
     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',
     })
 
@@ -494,7 +453,7 @@ describe('<ShareProjectModal/>', 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)
 
     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(
       await screen.findAllByText('member-viewer@example.com')
@@ -537,7 +491,7 @@ describe('<ShareProjectModal/>', 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)
 
     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')
     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 () {
-    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')
 
@@ -680,26 +625,24 @@ describe('<ShareProjectModal/>', 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(
       '/event/paywall-prompt',
       {},
       { 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')
 
@@ -719,24 +662,22 @@ describe('<ShareProjectModal/>', 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')
 
@@ -757,16 +698,14 @@ describe('<ShareProjectModal/>', 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
     await waitFor(() => {
@@ -818,21 +757,25 @@ describe('<ShareProjectModal/>', 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)
 
     let setPublicAccessLevel = function () {}
 
     function WrappedModal() {
-      setPublicAccessLevel = useScopeValue('project.publicAccesLevel')[1]
+      const { updateProject } = useProjectContext()
+      setPublicAccessLevel = publicAccessLevel => {
+        updateProject({ publicAccessLevel })
+      }
       return <ShareProjectModal {...modalProps} />
     }
 
-    renderWithEditorContext(<WrappedModal />, {
-      scope: {
-        project: { ...project, publicAccesLevel: 'private' },
-      },
-    })
+    renderWithEditorContext(
+      <WrappedModal />,
+      createContextProps({
+        publicAccessLevel: 'private',
+      })
+    )
 
     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
-    // 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')
 
     await screen.findByText('Link sharing is on')
@@ -870,9 +813,10 @@ describe('<ShareProjectModal/>', function () {
   })
 
   it('avoids selecting unmatched contact', async function () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
     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 () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
     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 () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
     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 () {
-    renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
-      scope: { project },
-    })
+    renderWithEditorContext(
+      <ShareProjectModal {...modalProps} />,
+      createContextProps()
+    )
 
     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()
-    scope.project.rootFolder = rootFolder
 
     cy.mount(
       <TestContainer>
@@ -446,7 +445,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
     ]
 
     const scope = mockScope()
-    scope.project.rootFolder = rootFolder
 
     cy.mount(
       <TestContainer>
@@ -910,7 +908,6 @@ describe('autocomplete', { scrollBehavior: false }, function () {
     ]
 
     const scope = mockScope()
-    scope.project.rootFolder = rootFolder
 
     cy.mount(
       <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 {
   EditorProviders,
   makeEditorPropertiesProvider,
+  makeProjectProvider,
+  USER_ID,
 } from '../../../helpers/editor-providers'
 import { mockScope, rootFolderId } from '../helpers/mock-scope'
 import { FC } from 'react'
 import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { TestContainer } from '../helpers/test-container'
 import getMeta from '@/utils/meta'
+import { mockProject } from '../helpers/mock-project'
 
 const clickToolbarButton = (text: string) => {
   cy.findByLabelText(text).click()
@@ -44,6 +47,11 @@ describe('<FigureModal />', function () {
   function mount() {
     const content = ''
     const scope = mockScope(content)
+    const project = mockProject({
+      projectOwner: {
+        _id: USER_ID,
+      },
+    })
 
     const FileTreePathProvider: FC<React.PropsWithChildren> = ({
       children,
@@ -69,6 +77,7 @@ describe('<FigureModal />', function () {
           scope={scope}
           providers={{
             FileTreePathProvider,
+            ProjectProvider: makeProjectProvider(project),
             EditorPropertiesProvider: makeEditorPropertiesProvider({
               showVisual: true,
               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 { EditorProviders } from '../../../helpers/editor-providers'
+import {
+  EditorProviders,
+  makeProjectProvider,
+} from '../../../helpers/editor-providers'
 import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { TestContainer } from '../helpers/test-container'
 import forEach from 'mocha-each'
 import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
+import { mockProject } from '../helpers/mock-project'
 
 const languages = [
   { code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
@@ -125,11 +129,14 @@ forEach(Object.keys(suggestions)).describe(
       cy.interceptEvents()
 
       const scope = mockScope(content)
-      scope.project.spellCheckLanguage = spellCheckLanguage
+      const project = mockProject({ spellCheckLanguage })
 
       cy.mount(
         <TestContainer>
-          <EditorProviders scope={scope}>
+          <EditorProviders
+            scope={scope}
+            providers={{ ProjectProvider: makeProjectProvider(project) }}
+          >
             <CodeMirrorEditor />
           </EditorProviders>
         </TestContainer>

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

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

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

@@ -1,20 +1,24 @@
 import {
   EditorProviders,
   makeEditorPropertiesProvider,
+  makeProjectProvider,
 } from '../../../helpers/editor-providers'
 import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { mockScope } from '../helpers/mock-scope'
 import { TestContainer } from '../helpers/test-container'
 import { isMac } from '@/shared/utils/os'
+import { mockProject } from '../helpers/mock-project'
 
 const mountEditor = (content: string) => {
   const scope = mockScope(content)
+  const project = mockProject()
 
   cy.mount(
     <TestContainer>
       <EditorProviders
         scope={scope}
         providers={{
+          ProjectProvider: makeProjectProvider(project),
           EditorPropertiesProvider: makeEditorPropertiesProvider({
             showVisual: true,
             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 { sleep } from '../../../helpers/sleep'
-import { Folder } from '../../../../../types/folder'
 
 export const rootFolderId = '012345678901234567890123'
-export const figuresFolderId = '123456789012345678901234'
-export const figureId = '234567890123456789012345'
 export const mockScope = (
   content?: string,
-  {
-    docOptions = {},
-    projectFeatures = {},
-    permissions = {},
-    projectOwner = undefined,
-  }: any = {}
+  { docOptions = {}, permissions = {} }: any = {}
 ) => {
   return {
     editor: {
@@ -21,55 +13,8 @@ export const mockScope = (
       currentDocumentId: docId,
       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: {
       comment: true,

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

@@ -23,10 +23,12 @@ import {
   EditorOpenDocContext,
   type EditorOpenDocContextState,
 } 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 useEventListener from '@/shared/hooks/use-event-listener'
 import useDetachLayout from '@/shared/hooks/use-detach-layout'
 import useExposedState from '@/shared/hooks/use-exposed-state'
+import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
 import {
   EditorPropertiesContext,
   EditorPropertiesContextValue,
@@ -42,6 +44,12 @@ import type { PermissionsLevel } from '@/features/ide-react/types/permissions'
 import type { Folder } from '../../../types/folder'
 import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state'
 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
 // using magic strings
@@ -68,10 +76,11 @@ const defaultUserSettings = {
 export type EditorProvidersProps = {
   user?: { id: string; email: string }
   projectId?: string
-  projectOwner?: { _id: string; email: string }
+  projectName?: string
+  projectOwner?: ProjectMetadata['owner']
   rootDocId?: string
   imageName?: string
-  compiler?: string
+  compiler?: ProjectCompiler
   socket?: Socket
   isRestrictedTokenMember?: boolean
   scope?: Record<string, any>
@@ -85,6 +94,46 @@ export type EditorProvidersProps = {
   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 = {
   view: 'editor',
   openFile: null,
@@ -99,37 +148,23 @@ const layoutContextDefault = {
 
 export function EditorProviders({
   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,
   isRestrictedTokenMember = false,
   scope: defaultScope = {},
   features = {
     referencesSearch: true,
+    gitBridge: false,
   },
   projectFeatures = features,
   permissionsLevel = 'owner',
   children,
-  rootFolder = [
-    {
-      _id: 'root-folder-id',
-      name: 'rootFolder',
-      docs: [
-        {
-          _id: '_root_doc_id',
-          name: 'main.tex',
-        },
-      ],
-      folders: [],
-      fileRefs: [],
-    },
-  ],
+  rootFolder = projectDefaults.rootFolder,
   /** @type {Partial<LayoutContext>} */
   layoutContext = layoutContextDefault,
   userSettings = {},
@@ -166,21 +201,26 @@ export function EditorProviders({
         currentDocumentId: null,
         wantTrackChanges: false,
       },
-      project: {
-        _id: projectId,
-        name: PROJECT_NAME,
-        owner: projectOwner,
-        features: projectFeatures,
-        rootDocId,
-        rootFolder,
-        imageName,
-        compiler,
-      },
       permissionsLevel,
     },
     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
   window.metaAttributesCache.set('ol-user', { ...user, features })
   window.metaAttributesCache.set('ol-project_id', projectId)
@@ -199,6 +239,7 @@ export function EditorProviders({
           wantTrackChanges: scope.editor.wantTrackChanges,
         }),
         LayoutProvider: makeLayoutProvider(layoutContext),
+        ProjectProvider: makeProjectProvider(project),
         ...providers,
       }}
     >
@@ -503,3 +544,35 @@ export function makeEditorPropertiesProvider(
 
   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
+}