From 7a5b1da06d192cca3140b41d22c305d0d29fd671 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Fri, 31 Jul 2026 09:51:09 +0100 Subject: [PATCH] Merge pull request #36034 from overleaf/mj-free-user-intermittent [web] differentiate free and paid users for intermittent connection timeouts GitOrigin-RevId: 9c1847ff3dae7290b7c8d1093ef6dc05ae3b76aa --- .../context/unsaved-docs-context.tsx | 28 +++-- .../context/unsaved-docs-context.spec.tsx | 103 +++++++++++++++++- 2 files changed, 115 insertions(+), 16 deletions(-) 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 index 5bee11fa63..ad9bcb9a7b 100644 --- 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 @@ -12,17 +12,11 @@ import { useEditorManagerContext } from '@/features/ide-react/context/editor-man 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' -import { isSplitTestEnabled } from '@/utils/splitTestUtils' - -const intermittentConnectionImprovementsEnabled = isSplitTestEnabled( - 'intermittent-connection-improvements' -) +import getMeta from '@/utils/meta' +import { useFeatureFlag } from '@/shared/context/split-test-context' const STALL_AFTER_SECONDS = 10 // treat saving as stalled after this time -// lock the editor after this time if unsaved -const MAX_UNSAVED_SECONDS = intermittentConnectionImprovementsEnabled ? 600 : 30 - type UnsavedDocsContextValue = { unsavedDocs: Map isSavingStalled: boolean @@ -40,7 +34,21 @@ export const UnsavedDocsProvider: FC = ({ const { permissionsLevel, setPermissionsLevel } = useIdeReactContext() const [isLocked, setIsLocked] = useState(false) const [unsavedDocs, setUnsavedDocs] = useState(new Map()) + const isPremiumUser = Boolean(getMeta('ol-user')?.features?.versioning) + const intermittentConnectionImprovementsEnabled = useFeatureFlag( + 'intermittent-connection-improvements' + ) + // lock the editor after this time if unsaved + const MAX_UNSAVED_SECONDS_FREE = intermittentConnectionImprovementsEnabled + ? 120 + : 30 + const MAX_UNSAVED_SECONDS_PREMIUM = intermittentConnectionImprovementsEnabled + ? 600 + : 30 + const maxUnsavedSecondsLimit = isPremiumUser + ? MAX_UNSAVED_SECONDS_PREMIUM + : MAX_UNSAVED_SECONDS_FREE // always contains the latest value const previousUnsavedDocsRef = useRef(unsavedDocs) @@ -101,8 +109,8 @@ export const UnsavedDocsProvider: FC = ({ // lock the editor if at least one doc has been unsaved for too long useEffect(() => { - setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS) - }, [maxUnsavedSeconds]) + setIsLocked(maxUnsavedSeconds > maxUnsavedSecondsLimit) + }, [maxUnsavedSeconds, maxUnsavedSecondsLimit]) // display a modal and set the permissions level to readOnly if docs have been unsaved for too long const originalPermissionsLevelRef = useRef(null) 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 index 3bddf85871..43f4d7e9ee 100644 --- 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 @@ -2,6 +2,7 @@ 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 { SplitTestProvider } from '@/shared/context/split-test-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' @@ -34,8 +35,26 @@ function makeOpenDocsStub( function mount( openDocs = makeOpenDocsStub(), - onSetPermissionsLevel: (level: PermissionsLevel) => void = () => {} + onSetPermissionsLevel: (level: PermissionsLevel) => void = () => {}, + { + isPremiumUser = false, + intermittentConnectionImprovementsEnabled = false, + }: { + isPremiumUser?: boolean + intermittentConnectionImprovementsEnabled?: boolean + } = {} ) { + // the provider reads getMeta('ol-user') to decide the lock threshold; the + // `versioning` feature marks a premium user + window.metaAttributesCache.set('ol-user', { + features: isPremiumUser ? { versioning: true } : {}, + }) + // the provider reads the split test variant via useFeatureFlag + window.metaAttributesCache.set('ol-splitTestVariants', { + 'intermittent-connection-improvements': + intermittentConnectionImprovementsEnabled ? 'enabled' : 'default', + }) + const debugTimers = { current: {} as Record } const Wrapper: FC = () => { @@ -53,11 +72,15 @@ function mount( })) return ( - - - - - + + + + + + + ) } @@ -117,6 +140,74 @@ describe('', function () { cy.get('@setPermissionsLevel').should('have.been.calledWith', 'owner') }) + describe('with intermittent-connection-improvements enabled', function () { + it('does not lock a free user before the raised 120s threshold', function () { + const setPermissionsLevel = cy.stub().as('setPermissionsLevel') + const now = performance.now() + // 100s would exceed the default 30s limit, but not the raised free limit + const openDocs = makeOpenDocsStub([ + makeDocStub('doc1', { pendingAt: now - 100_000 }), + ]) + + mount(openDocs, setPermissionsLevel, { + isPremiumUser: false, + intermittentConnectionImprovementsEnabled: true, + }) + cy.tick(1000) + + cy.get('@setPermissionsLevel').should('not.have.been.called') + }) + + it('locks a free user after the raised 120s threshold', function () { + const setPermissionsLevel = cy.stub().as('setPermissionsLevel') + const now = performance.now() + const openDocs = makeOpenDocsStub([ + makeDocStub('doc1', { pendingAt: now - 121_000 }), + ]) + + mount(openDocs, setPermissionsLevel, { + isPremiumUser: false, + intermittentConnectionImprovementsEnabled: true, + }) + cy.tick(1000) + + cy.get('@setPermissionsLevel').should('have.been.calledWith', 'readOnly') + }) + + it('does not lock a premium user before the raised 600s threshold', function () { + const setPermissionsLevel = cy.stub().as('setPermissionsLevel') + const now = performance.now() + // 200s exceeds the free 120s limit, but not the premium 600s limit + const openDocs = makeOpenDocsStub([ + makeDocStub('doc1', { pendingAt: now - 200_000 }), + ]) + + mount(openDocs, setPermissionsLevel, { + isPremiumUser: true, + intermittentConnectionImprovementsEnabled: true, + }) + cy.tick(1000) + + cy.get('@setPermissionsLevel').should('not.have.been.called') + }) + + it('locks a premium user after the raised 600s threshold', function () { + const setPermissionsLevel = cy.stub().as('setPermissionsLevel') + const now = performance.now() + const openDocs = makeOpenDocsStub([ + makeDocStub('doc1', { pendingAt: now - 601_000 }), + ]) + + mount(openDocs, setPermissionsLevel, { + isPremiumUser: true, + intermittentConnectionImprovementsEnabled: true, + }) + cy.tick(1000) + + cy.get('@setPermissionsLevel').should('have.been.calledWith', 'readOnly') + }) + }) + it('calls event.preventDefault() on beforeunload when there are unsaved changes', function () { const openDocs = makeOpenDocsStub([ makeDocStub('doc1', { pendingAt: performance.now() - 5_000 }),