From df4e33639905046508831e081f60ff2e2ab28a2a Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Fri, 10 Jul 2026 09:59:33 +0100 Subject: [PATCH] Merge pull request #34245 from overleaf/mj-unsaved-docs-context [web] Move unsaved docs monitoring to context GitOrigin-RevId: 6d2c778efb7a52616ba1a1d52c617d8170a7f798 --- .../components/unsaved-docs/unsaved-docs.tsx | 99 +----------- .../ide-react/context/react-context-root.tsx | 54 ++++--- .../context/unsaved-docs-context.tsx | 141 +++++++++++++++++ .../context/unsaved-docs-context.spec.tsx | 142 ++++++++++++++++++ .../source-editor/components/tabs.spec.tsx | 10 +- 5 files changed, 324 insertions(+), 122 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/context/unsaved-docs-context.tsx create mode 100644 services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx diff --git a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx index 7f19737c90..a038a37bf0 100644 --- a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx +++ b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx @@ -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()) + 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(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 } diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index 0bb5ca2626..6b93b87ff8 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -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< - - - - - - - - - - - - { - childrenWrappedWithDynamicProviders - } - - - - - - - - - - - + + + + + + + + + + + + + { + childrenWrappedWithDynamicProviders + } + + + + + + + + + + + + diff --git a/services/web/frontend/js/features/ide-react/context/unsaved-docs-context.tsx b/services/web/frontend/js/features/ide-react/context/unsaved-docs-context.tsx new file mode 100644 index 0000000000..746c757137 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/context/unsaved-docs-context.tsx @@ -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 + isLocked: boolean +} + +const UnsavedDocsContext = createContext( + undefined +) + +export const UnsavedDocsProvider: FC = ({ + children, +}) => { + const { openDocs, debugTimers } = useEditorManagerContext() + const { permissionsLevel, setPermissionsLevel } = useIdeReactContext() + const [isLocked, setIsLocked] = useState(false) + const [unsavedDocs, setUnsavedDocs] = useState(new Map()) + + // 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(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 ( + + {children} + + ) +} + +export function useUnsavedDocsContext(): UnsavedDocsContextValue { + const context = useContext(UnsavedDocsContext) + + if (!context) { + throw new Error( + 'useUnsavedDocsContext is only available inside UnsavedDocsProvider' + ) + } + + return context +} diff --git a/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx b/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx new file mode 100644 index 0000000000..132990caa1 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx @@ -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 { + return { + doc_id: docId, + getInflightOpCreatedAt: () => inflightAt ?? undefined, + getPendingOpCreatedAt: () => pendingAt ?? undefined, + hasBufferedOps: () => inflightAt != null || pendingAt != null, + pollSavedStatus: () => inflightAt == null && pendingAt == null, + } +} + +function makeOpenDocsStub( + docsWithUnsavedOps: Partial[] = [] +) { + 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 } + + 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 ( + + + + + + ) + } + + cy.mount() +} + +describe('', 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 + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx b/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx index ce01395030..ae0b2f1d1b 100644 --- a/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx @@ -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,