mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
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:
+3
-96
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user