Merge pull request #34245 from overleaf/mj-unsaved-docs-context

[web] Move unsaved docs monitoring to context

GitOrigin-RevId: 6d2c778efb7a52616ba1a1d52c617d8170a7f798
This commit is contained in:
Davinder Singh
2026-09-17 11:22:28 +00:00
committed by Copybot
parent c4104f3bd7
commit df4e336399
5 changed files with 324 additions and 122 deletions
@@ -1,107 +1,14 @@
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { FC } from 'react'
import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert'
import { UnsavedDocsAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-alert'
import useEventListener from '@/shared/hooks/use-event-listener'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
const MAX_UNSAVED_SECONDS = 30 // lock the editor after this time if unsaved
import { useUnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs-context'
export const UnsavedDocs: FC = () => {
const { openDocs, debugTimers } = useEditorManagerContext()
const { permissionsLevel, setPermissionsLevel } = useIdeReactContext()
const [isLocked, setIsLocked] = useState(false)
const [unsavedDocs, setUnsavedDocs] = useState(new Map<string, number>())
const { unsavedDocs, isLocked } = useUnsavedDocsContext()
const globalAlertsContainer = useGlobalAlertsContainer()
// always contains the latest value
const previousUnsavedDocsRef = useRef(unsavedDocs)
// always contains the latest value
const permissionsLevelRef = useRef(permissionsLevel)
useEffect(() => {
permissionsLevelRef.current = permissionsLevel
}, [permissionsLevel])
// warn if the window is being closed with unsaved changes
useEventListener(
'beforeunload',
useCallback(
(event: BeforeUnloadEvent) => {
if (openDocs.hasUnsavedChanges()) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
event.preventDefault()
}
},
[openDocs]
)
)
// keep track of which docs are currently unsaved, and how long they've been unsaved for
// NOTE: openDocs should never change, so it's safe to use as a dependency here
useEffect(() => {
const interval = window.setInterval(() => {
debugTimers.current.CheckUnsavedDocs = Date.now()
const unsavedDocs = new Map()
const docs = openDocs.unsavedDocs()
for (const doc of docs) {
const oldestOpCreatedAt =
doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
if (oldestOpCreatedAt) {
const unsavedSeconds = Math.floor(
(performance.now() - oldestOpCreatedAt) / 1000
)
unsavedDocs.set(doc.doc_id, unsavedSeconds)
}
}
// avoid setting the unsavedDocs state to a new empty Map every second
if (unsavedDocs.size > 0 || previousUnsavedDocsRef.current.size > 0) {
previousUnsavedDocsRef.current = unsavedDocs
setUnsavedDocs(unsavedDocs)
}
}, 1000)
return () => {
window.clearInterval(interval)
}
}, [openDocs, debugTimers])
const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values())
// lock the editor if at least one doc has been unsaved for too long
useEffect(() => {
setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS)
}, [maxUnsavedSeconds])
// display a modal and set the permissions level to readOnly if docs have been unsaved for too long
const originalPermissionsLevelRef = useRef<PermissionsLevel | null>(null)
useEffect(() => {
if (isLocked) {
originalPermissionsLevelRef.current = permissionsLevelRef.current
// TODO: what if the real permissions level changes in the meantime?
// TODO: perhaps the "locked" state should be stored in the editor context instead?
setPermissionsLevel('readOnly')
setIsLocked(true)
} else {
if (originalPermissionsLevelRef.current) {
setPermissionsLevel(originalPermissionsLevelRef.current)
}
}
}, [isLocked, setPermissionsLevel])
// remove the modal (and unlock the page) if the connection has been re-established and all the docs have been saved
useEffect(() => {
if (unsavedDocs.size === 0 && permissionsLevelRef.current === 'readOnly') {
setIsLocked(false)
}
}, [unsavedDocs])
if (!globalAlertsContainer) {
return null
}
@@ -33,6 +33,7 @@ import { EditorSelectionProvider } from '@/shared/context/editor-selection-conte
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { TutorialProvider } from '@/shared/context/tutorial-context'
import { TabsProvider } from './tabs-context'
import { UnsavedDocsProvider } from '@/features/ide-react/context/unsaved-docs-context'
const rootContextProviders = importOverleafModules('rootContextProviders') as {
import: { default: ElementType }
@@ -78,6 +79,7 @@ export const ReactContextRoot: FC<
EditorSelectionProvider,
TutorialProvider,
TabsProvider,
UnsavedDocsProvider,
...providers,
}
@@ -116,31 +118,33 @@ export const ReactContextRoot: FC<
<Providers.LayoutProvider>
<Providers.ProjectSettingsProvider>
<Providers.EditorManagerProvider>
<Providers.ReferencesProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.TabsProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.TabsProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.ReferencesProvider>
<Providers.UnsavedDocsProvider>
<Providers.ReferencesProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.TabsProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.TabsProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.ReferencesProvider>
</Providers.UnsavedDocsProvider>
</Providers.EditorManagerProvider>
</Providers.ProjectSettingsProvider>
</Providers.LayoutProvider>
@@ -0,0 +1,141 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import useEventListener from '@/shared/hooks/use-event-listener'
const MAX_UNSAVED_SECONDS = 30 // lock the editor after this time if unsaved
type UnsavedDocsContextValue = {
unsavedDocs: Map<string, number>
isLocked: boolean
}
const UnsavedDocsContext = createContext<UnsavedDocsContextValue | undefined>(
undefined
)
export const UnsavedDocsProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { openDocs, debugTimers } = useEditorManagerContext()
const { permissionsLevel, setPermissionsLevel } = useIdeReactContext()
const [isLocked, setIsLocked] = useState(false)
const [unsavedDocs, setUnsavedDocs] = useState(new Map<string, number>())
// always contains the latest value
const previousUnsavedDocsRef = useRef(unsavedDocs)
// always contains the latest value
const permissionsLevelRef = useRef(permissionsLevel)
useEffect(() => {
permissionsLevelRef.current = permissionsLevel
}, [permissionsLevel])
// warn if the window is being closed with unsaved changes
useEventListener(
'beforeunload',
useCallback(
(event: BeforeUnloadEvent) => {
if (openDocs.hasUnsavedChanges()) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
event.preventDefault()
}
},
[openDocs]
)
)
// keep track of which docs are currently unsaved, and how long they've been unsaved for
// NOTE: openDocs should never change, so it's safe to use as a dependency here
useEffect(() => {
const interval = window.setInterval(() => {
debugTimers.current.CheckUnsavedDocs = Date.now()
const unsavedDocs = new Map()
const docs = openDocs.unsavedDocs()
for (const doc of docs) {
const oldestOpCreatedAt =
doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
if (oldestOpCreatedAt) {
const unsavedSeconds = Math.floor(
(performance.now() - oldestOpCreatedAt) / 1000
)
unsavedDocs.set(doc.doc_id, unsavedSeconds)
}
}
// avoid setting the unsavedDocs state to a new empty Map every second
if (unsavedDocs.size > 0 || previousUnsavedDocsRef.current.size > 0) {
previousUnsavedDocsRef.current = unsavedDocs
setUnsavedDocs(unsavedDocs)
}
}, 1000)
return () => {
window.clearInterval(interval)
}
}, [openDocs, debugTimers])
const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values())
// lock the editor if at least one doc has been unsaved for too long
useEffect(() => {
setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS)
}, [maxUnsavedSeconds])
// display a modal and set the permissions level to readOnly if docs have been unsaved for too long
const originalPermissionsLevelRef = useRef<PermissionsLevel | null>(null)
useEffect(() => {
if (isLocked) {
originalPermissionsLevelRef.current = permissionsLevelRef.current
// TODO: what if the real permissions level changes in the meantime?
// TODO: perhaps the "locked" state should be stored in the editor context instead?
setPermissionsLevel('readOnly')
} else {
if (originalPermissionsLevelRef.current) {
setPermissionsLevel(originalPermissionsLevelRef.current)
}
}
}, [isLocked, setPermissionsLevel])
// remove the modal (and unlock the page) if the connection has been re-established and all the docs have been saved
useEffect(() => {
if (unsavedDocs.size === 0 && permissionsLevelRef.current === 'readOnly') {
setIsLocked(false)
}
}, [unsavedDocs])
const value = useMemo(
() => ({ unsavedDocs, isLocked }),
[unsavedDocs, isLocked]
)
return (
<UnsavedDocsContext.Provider value={value}>
{children}
</UnsavedDocsContext.Provider>
)
}
export function useUnsavedDocsContext(): UnsavedDocsContextValue {
const context = useContext(UnsavedDocsContext)
if (!context) {
throw new Error(
'useUnsavedDocsContext is only available inside UnsavedDocsProvider'
)
}
return context
}
@@ -0,0 +1,142 @@
import { FC, useState } from 'react'
import { EditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { IdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { UnsavedDocsProvider } from '@/features/ide-react/context/unsaved-docs-context'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
import type { PermissionsLevel } from '@/features/ide-react/types/permissions'
function makeDocStub(
docId: string,
{
inflightAt = null,
pendingAt = null,
}: { inflightAt?: number | null; pendingAt?: number | null } = {}
): Partial<DocumentContainer> {
return {
doc_id: docId,
getInflightOpCreatedAt: () => inflightAt ?? undefined,
getPendingOpCreatedAt: () => pendingAt ?? undefined,
hasBufferedOps: () => inflightAt != null || pendingAt != null,
pollSavedStatus: () => inflightAt == null && pendingAt == null,
}
}
function makeOpenDocsStub(
docsWithUnsavedOps: Partial<DocumentContainer>[] = []
) {
return {
unsavedDocs: () => docsWithUnsavedOps,
hasUnsavedChanges: () => docsWithUnsavedOps.length > 0,
getUnsavedOpsSize: () => ({ pendingOpsLength: 0, inflightOpsLength: 0 }),
}
}
function mount(
openDocs = makeOpenDocsStub(),
onSetPermissionsLevel: (level: PermissionsLevel) => void = () => {}
) {
const debugTimers = { current: {} as Record<string, number> }
const Wrapper: FC = () => {
const [contextValue] = useState(() => ({
projectId: 'test-project',
eventEmitter: new IdeEventEmitter(),
startedFreeTrial: false,
setStartedFreeTrial: () => {},
reportError: () => {},
projectJoined: true,
permissionsLevel: 'owner' as PermissionsLevel,
setPermissionsLevel: onSetPermissionsLevel,
setOutOfSync: () => {},
}))
return (
<IdeReactContext.Provider value={contextValue}>
<EditorManagerContext.Provider value={{ openDocs, debugTimers } as any}>
<UnsavedDocsProvider />
</EditorManagerContext.Provider>
</IdeReactContext.Provider>
)
}
cy.mount(<Wrapper />)
}
describe('<UnsavedDocsProvider />', function () {
beforeEach(function () {
cy.clock()
})
it('locks the editor when a doc exceeds MAX_UNSAVED_SECONDS', function () {
const setPermissionsLevel = cy.stub().as('setPermissionsLevel')
const now = performance.now()
const openDocs = makeOpenDocsStub([
makeDocStub('doc1', { pendingAt: now - 31_000 }),
])
mount(openDocs, setPermissionsLevel)
cy.tick(1000)
cy.get('@setPermissionsLevel').should('have.been.calledWith', 'readOnly')
})
it('does not lock the editor before MAX_UNSAVED_SECONDS', function () {
const setPermissionsLevel = cy.stub().as('setPermissionsLevel')
const now = performance.now()
const openDocs = makeOpenDocsStub([
makeDocStub('doc1', { pendingAt: now - 29_000 }),
])
mount(openDocs, setPermissionsLevel)
cy.tick(1000)
cy.get('@setPermissionsLevel').should('not.have.been.called')
})
it('restores permissions when all docs are saved after locking', function () {
const setPermissionsLevel = cy.stub().as('setPermissionsLevel')
const now = performance.now()
const openDocs = makeOpenDocsStub([
makeDocStub('doc1', { pendingAt: now - 31_000 }),
])
mount(openDocs, setPermissionsLevel)
cy.tick(1000)
cy.get('@setPermissionsLevel')
.should('have.been.calledWith', 'readOnly')
.then(() => {
openDocs.unsavedDocs = () => []
openDocs.hasUnsavedChanges = () => false
})
cy.tick(1000)
cy.get('@setPermissionsLevel').should('have.been.calledWith', 'owner')
})
it('calls event.preventDefault() on beforeunload when there are unsaved changes', function () {
const openDocs = makeOpenDocsStub([
makeDocStub('doc1', { pendingAt: performance.now() - 5_000 }),
])
mount(openDocs)
cy.window().then(win => {
const event = new win.Event('beforeunload', { cancelable: true })
win.dispatchEvent(event)
expect(event.defaultPrevented).to.be.true
})
})
it('does not prevent beforeunload when all docs are saved', function () {
mount()
cy.window().then(win => {
const event = new win.Event('beforeunload', { cancelable: true })
win.dispatchEvent(event)
expect(event.defaultPrevented).to.be.false
})
})
})
@@ -112,7 +112,15 @@ function makeEditorManagerProvider() {
setIgnoringExternalUpdates: () => {},
openDocWithId: cy.stub().as('openDocWithId').resolves(),
openDoc: cy.stub().as('openDoc').resolves(),
openDocs: { awaitBufferedOps: cy.stub().resolves() } as any,
openDocs: {
awaitBufferedOps: cy.stub().resolves(),
unsavedDocs: () => [],
hasUnsavedChanges: () => false,
getUnsavedOpsSize: () => ({
pendingOpsLength: 0,
inflightOpsLength: 0,
}),
} as any,
openFileWithId: cy.stub().as('openFileWithId'),
openInitialDoc: cy.stub().resolves(),
isLoading: false,