diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 93345c84bc..8fe50ea745 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -2577,6 +2577,7 @@
"youre_about_to_enable_single_sign_on_sso_only": "",
"youre_adding_x_licenses_to_your_plan_giving_you_a_total_of_y_licenses": "",
"youre_already_setup_for_sso": "",
+ "youre_back_online": "",
"youre_creating_account_for_x_it_will_be_managed_by_y": "",
"youre_joining": "",
"youre_joining_x_as_y": "",
diff --git a/services/web/frontend/js/features/ide-react/components/connection-restored-toast.tsx b/services/web/frontend/js/features/ide-react/components/connection-restored-toast.tsx
new file mode 100644
index 0000000000..60eb45191a
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/connection-restored-toast.tsx
@@ -0,0 +1,34 @@
+import { useTranslation } from 'react-i18next'
+import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
+
+const CONNECTION_RESTORED_TOAST_HANDLE = 'connection-restored'
+
+const ConnectionRestoredToastContent = () => {
+ const { t } = useTranslation()
+ return {t('youre_back_online')}
+}
+
+const generators: GlobalToastGeneratorEntry[] = [
+ {
+ key: 'connection-restored',
+ generator: () => ({
+ content: ,
+ type: 'success',
+ autoHide: true,
+ delay: 5000,
+ }),
+ },
+]
+
+export default generators
+
+export const showConnectionRestoredToast = () => {
+ window.dispatchEvent(
+ new CustomEvent('ide:show-toast', {
+ detail: {
+ key: 'connection-restored',
+ handle: CONNECTION_RESTORED_TOAST_HANDLE,
+ },
+ })
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx
index e767b1fe1f..46c58972ee 100644
--- a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx
+++ b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx
@@ -7,6 +7,7 @@ import importOverleafModules from '../../../../macros/import-overleaf-module.mac
import { OLToastContainer } from '@/shared/components/ol/ol-toast-container'
import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts'
+import connectionRestoredToastGenerators from '@/features/ide-react/components/connection-restored-toast'
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
import: { default: GlobalToastGeneratorEntry[] }
@@ -29,6 +30,7 @@ const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
...moduleGenerators.flat(),
...clipboardToastGenerators,
...exportDocumentToastGenerators,
+ ...connectionRestoredToastGenerators,
]
const GENERATOR_MAP: Map = new Map(
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
index a46026645f..7182788a5c 100644
--- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
@@ -25,6 +25,8 @@ import {
findFileRefEntityById,
} from '@/features/ide-react/util/find-doc-entity-by-id'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
+import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
+import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup'
import { useTranslation } from 'react-i18next'
import customLocalStorage from '@/infrastructure/local-storage'
import useEventListener from '@/shared/hooks/use-event-listener'
@@ -35,7 +37,7 @@ import { useDebugDiffTracker } from '../hooks/use-debug-diff-tracker'
import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
-import { useFeatureFlag } from '@/shared/context/split-test-context'
+import { showConnectionRestoredToast } from '@/features/ide-react/components/connection-restored-toast'
export interface GotoOffsetOptions {
gotoOffset: number
@@ -86,8 +88,12 @@ export const EditorManagerProvider: FC = ({
useIdeReactContext()
const { socket, closeConnection, connectionState } = useConnectionContext()
const { view, setView, setOpenFile } = useLayoutContext()
- const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
- useModalsContext()
+ const {
+ showGenericMessageModal,
+ genericModalVisible,
+ showOutOfSyncModal,
+ showUnableToSyncModal,
+ } = useModalsContext()
const { id: userId } = useUserContext()
const {
showVisual,
@@ -119,9 +125,6 @@ export const EditorManagerProvider: FC = ({
projectId,
currentDocument
)
- const improvedFlakyConnections = useFeatureFlag(
- 'intermittent-connection-improvements'
- )
const [globalEditorWatchdogManager] = useState(
() =>
@@ -576,8 +579,21 @@ export const EditorManagerProvider: FC = ({
setErrorState(true)
// Ensure that the editor is locked
setOutOfSync(true)
- // Display the "out of sync" modal
- if (!improvedFlakyConnections) {
+ // A failure while there are recoverable offline edits is a failed sync,
+ // so report it through the same event as the recovery path and let the
+ // listener show the modal. Otherwise fall back to the out of sync modal.
+ const hasOfflineBackup = !!OfflineDocBackup.readRecoverable(
+ projectId,
+ document.doc_id
+ )
+ if (hasOfflineBackup) {
+ eventEmitter.emit('ide:unableToSyncOfflineChanges', {
+ docId: document.doc_id,
+ editorContent: editorContent || '',
+ docName: document.docName,
+ })
+ OfflineDocBackup.remove(projectId, document.doc_id)
+ } else {
showOutOfSyncModal(editorContent || '')
}
@@ -607,9 +623,35 @@ export const EditorManagerProvider: FC = ({
showOutOfSyncModal,
setOutOfSync,
t,
- improvedFlakyConnections,
+ projectId,
])
+ useEffect(() => {
+ const handleUnableToSync = ({
+ detail: [{ editorContent, docName }],
+ }: CustomEvent) => {
+ showUnableToSyncModal(editorContent, docName)
+ }
+
+ eventEmitter.on('ide:unableToSyncOfflineChanges', handleUnableToSync)
+
+ return () => {
+ eventEmitter.off('ide:unableToSyncOfflineChanges', handleUnableToSync)
+ }
+ }, [eventEmitter, showUnableToSyncModal])
+
+ useEffect(() => {
+ const handleOfflineChangesSynced = () => {
+ showConnectionRestoredToast()
+ }
+
+ eventEmitter.on('ide:offlineChangesSynced', handleOfflineChangesSynced)
+
+ return () => {
+ eventEmitter.off('ide:offlineChangesSynced', handleOfflineChangesSynced)
+ }
+ }, [eventEmitter])
+
useEventListener(
'editor:insert-symbol',
useCallback(() => {
diff --git a/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts b/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts
index 4bc1d44e95..f9ceba2a3a 100644
--- a/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts
+++ b/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts
@@ -22,6 +22,10 @@ export type IdeEvents = {
'comment:start_adding': []
'history:toggle': []
'entity:deleted': [entity: FileTreeFindResult]
+ 'ide:unableToSyncOfflineChanges': [
+ { docId: string; editorContent: string; docName: string },
+ ]
+ 'ide:offlineChangesSynced': [{ docId: string }]
}
export class IdeEventEmitter extends EventTarget {
diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts
index 732eccd9e6..f864186726 100644
--- a/services/web/frontend/js/features/ide-react/editor/document-container.ts
+++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts
@@ -33,7 +33,6 @@ import {
OfflineDocBackup,
OfflineDocBackupRecord,
} from '@/features/ide-react/editor/offline-doc-backup'
-import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import {
HistoryOTShareDoc,
ShareLatexOTShareDoc,
@@ -530,7 +529,10 @@ export class DocumentContainer extends EventEmitter {
}
)
} else {
- const record = this.getOfflineBackupForRecovery()
+ const record = OfflineDocBackup.readRecoverable(
+ getMeta('ol-project_id'),
+ this.doc_id
+ )
if (record) {
this.recoverAtBaseline(record, callback)
} else {
@@ -580,20 +582,6 @@ export class DocumentContainer extends EventEmitter {
this.bindToShareJsDocEvents()
}
- private getOfflineBackupForRecovery(): OfflineDocBackupRecord | null {
- if (!isSplitTestEnabled('intermittent-connection-improvements')) {
- return null
- }
- const record = OfflineDocBackup.read(getMeta('ol-project_id'), this.doc_id)
- // TODO(35594): tracked-change edits need their track-changes state (user id
- // + seeds) wired up during recovery to replay as tracked; skip them for now
- // rather than silently recovering them as untracked edits.
- if (!record || record.trackChanges) {
- return null
- }
- return record
- }
-
// Recover a doc from its offline backup: rebuild it at the backed-up baseline
// with the buffered edits restored, then re-join from that version so the
// server streams the ops that landed while we were away. catchUp transforms
@@ -605,9 +593,14 @@ export class DocumentContainer extends EventEmitter {
callback?: JoinCallback
) {
const loadWithoutRecovery = (error: unknown) => {
- // TODO(35594): surface the "changes can't be restored" modal.
debugConsole.error('[recovery] failed, loading without recovery', error)
- OfflineDocBackup.remove(getMeta('ol-project_id'), record.docId)
+ const editorContent = this.doc?.getSnapshot() || record.snapshot
+ this.ideEventEmitter.emit('ide:unableToSyncOfflineChanges', {
+ docId: record.docId,
+ editorContent,
+ docName: this.docName,
+ })
+ OfflineDocBackup.remove(record.projectId, record.docId)
this.doc?.clearInflightAndPendingOps()
this.doc = undefined
this.joinFreshDoc(callback)
@@ -753,7 +746,8 @@ export class DocumentContainer extends EventEmitter {
this.offlineBackup = new OfflineDocBackup(
this.doc,
- getMeta('ol-project_id')
+ getMeta('ol-project_id'),
+ this.ideEventEmitter
)
this.doc.on('error', (error: Error, meta: ErrorMetadata) =>
diff --git a/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts b/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts
index 8d9eaca209..67ed2b2fb7 100644
--- a/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts
+++ b/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts
@@ -3,6 +3,7 @@ import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import getMeta from '@/utils/meta'
import { ShareJsDoc } from './share-js-doc'
import { ShareJsOperation } from './types/document'
+import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
const EVENT_NAMESPACE = 'offlineBackup'
const OFFLINE_BACKUP_INTERVAL_MS = 2000
@@ -46,6 +47,26 @@ export class OfflineDocBackup {
)
}
+ // Whether a stored record is eligible to be replayed onto the live doc. The
+ // unable-to-sync modal is only correct for docs we actually tried to recover,
+ // so read-side callers must agree with the recovery path on eligibility.
+ static readRecoverable(
+ projectId: string,
+ docId: string
+ ): OfflineDocBackupRecord | null {
+ if (!isSplitTestEnabled('intermittent-connection-improvements')) {
+ return null
+ }
+ const record = OfflineDocBackup.read(projectId, docId)
+ // TODO(35594): tracked-change edits need their track-changes state (user id
+ // + seeds) wired up during recovery to replay as tracked; skip them for now
+ // rather than silently recovering them as untracked edits.
+ if (!record || record.trackChanges) {
+ return null
+ }
+ return record
+ }
+
static remove(projectId: string, docId: string): void {
customSessionStorage.removeItem(OfflineDocBackup.buildKey(projectId, docId))
}
@@ -59,7 +80,8 @@ export class OfflineDocBackup {
constructor(
private readonly doc: ShareJsDoc,
- private readonly projectId: string
+ private readonly projectId: string,
+ private readonly ideEventEmitter: IdeEventEmitter
) {
this.enabled = isSplitTestEnabled('intermittent-connection-improvements')
this.key = OfflineDocBackup.buildKey(projectId, doc.doc_id)
@@ -106,9 +128,22 @@ export class OfflineDocBackup {
return this.doc.getSnapshot()
}
+ // A stored record means this doc carried edits made while offline, so the
+ // server acknowledging them here is the point at which the sync succeeded.
+ // This covers both reconnecting with the doc still open and recovering the
+ // backup after a reload.
private onSaved() {
+ const hadOfflineEdits =
+ OfflineDocBackup.read(this.projectId, this.doc.doc_id) !== null
this.refreshBaseline()
this.clear()
+
+ if (!hadOfflineEdits) {
+ return
+ }
+ this.ideEventEmitter.emit('ide:offlineChangesSynced', {
+ docId: this.doc.doc_id,
+ })
}
private onDocChange() {
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 1e7dced8c2..9cce9a9382 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -3245,6 +3245,7 @@
"youre_about_to_enable_single_sign_on_sso_only": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct.",
"youre_adding_x_licenses_to_your_plan_giving_you_a_total_of_y_licenses": "You’re adding <0>__adding__0> licenses to your plan giving you a total of <1>__total__1> licenses.",
"youre_already_setup_for_sso": "You’re already set up for SSO",
+ "youre_back_online": "You’re back online.",
"youre_creating_account_for_x_it_will_be_managed_by_y": "You’re creating an account for __email__. It will be managed by __companyName__.",
"youre_joining": "You’re joining",
"youre_joining_x_as_y": "You’re joining __projectName__ as __email__",
diff --git a/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx b/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx
new file mode 100644
index 0000000000..4ae11b3681
--- /dev/null
+++ b/services/web/test/frontend/features/ide-react/context/editor-manager-context-out-of-sync.spec.tsx
@@ -0,0 +1,311 @@
+import { FC, useEffect } from 'react'
+import EventEmitter from '@/utils/EventEmitter'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
+import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
+import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup'
+import { GlobalToasts } from '@/features/ide-react/components/global-toasts'
+import {
+ EditorProviders,
+ makeEditorOpenDocProvider,
+ PROJECT_ID,
+ USER_ID,
+} from '../../../helpers/editor-providers'
+
+const CURRENT_DOC_ID = 'current-doc'
+const NEW_DOC_ID = 'new-doc'
+
+class FakeDocumentContainer extends EventEmitter {
+ doc_id = CURRENT_DOC_ID
+ docName = 'main.tex'
+ doc = { clearInflightAndPendingOps: cy.stub() }
+
+ getSnapshot() {
+ return 'server snapshot'
+ }
+ hasBufferedOps() {
+ return false
+ }
+ leaveAndCleanUp() {}
+ leaveAndCleanUpPromise() {
+ return Promise.resolve()
+ }
+}
+
+const OpenNewDocOnMount: FC = () => {
+ const editorManager = useEditorManagerContext()
+ useEffect(() => {
+ // Triggers openNewDocument → attaches the error handler onto the
+ // current fake DocumentContainer synchronously, before awaiting leave.
+ editorManager.openDoc({ _id: NEW_DOC_ID } as any).catch(() => {})
+ }, [editorManager])
+ return null
+}
+
+const CaptureEventEmitter: FC<{
+ onReady: (emitter: IdeEventEmitter) => void
+}> = ({ onReady }) => {
+ const { eventEmitter } = useIdeReactContext()
+ useEffect(() => {
+ onReady(eventEmitter)
+ }, [eventEmitter, onReady])
+ return null
+}
+
+function plantBackup(docId: string, { trackChanges = false } = {}) {
+ const key = OfflineDocBackup.buildKey(PROJECT_ID, docId)
+ window.sessionStorage.setItem(
+ key,
+ JSON.stringify({
+ docId,
+ projectId: PROJECT_ID,
+ version: 1,
+ snapshot: 'backup snapshot',
+ inflightOp: null,
+ pendingOp: null,
+ trackChanges,
+ updatedAt: Date.now(),
+ inflightSubmittedIds: [],
+ })
+ )
+ return key
+}
+
+function setSplitTest(enabled: boolean) {
+ window.metaAttributesCache.set('ol-splitTestVariants', {
+ 'intermittent-connection-improvements': enabled ? 'enabled' : 'default',
+ })
+}
+
+describe('EditorManagerProvider docError sync modals', function () {
+ let currentDoc: FakeDocumentContainer
+
+ beforeEach(function () {
+ window.sessionStorage.clear()
+ currentDoc = new FakeDocumentContainer()
+ cy.then(() => {
+ window.metaAttributesCache.set('ol-user_id', USER_ID)
+ })
+ })
+
+ afterEach(function () {
+ window.sessionStorage.clear()
+ })
+
+ const mount = () => {
+ cy.mount(
+
+
+
+ )
+ }
+
+ it('shows UnableToSyncModal when the split test is on and a backup exists', function () {
+ let key: string
+ cy.then(() => {
+ setSplitTest(true)
+ key = plantBackup(CURRENT_DOC_ID)
+ })
+
+ mount()
+
+ cy.then(() => {
+ currentDoc.trigger(
+ 'error',
+ new Error('forced'),
+ {},
+ 'offline edits content'
+ )
+ })
+
+ cy.findByRole('dialog').within(() => {
+ cy.findByText('Your offline edits couldn’t be synced').should('exist')
+ cy.findByRole('button', { name: 'Download local version' }).should(
+ 'exist'
+ )
+ })
+
+ cy.then(() => {
+ expect(window.sessionStorage.getItem(key!)).to.equal(null)
+ })
+ })
+
+ it('passes editorContent and docName through to the download button', function () {
+ cy.then(() => {
+ setSplitTest(true)
+ plantBackup(CURRENT_DOC_ID)
+ })
+
+ const captured: { blob: Blob | null; filename: string | null } = {
+ blob: null,
+ filename: null,
+ }
+
+ cy.window().then(win => {
+ cy.stub(win.URL, 'createObjectURL').callsFake((blob: Blob) => {
+ captured.blob = blob
+ return 'blob:mock'
+ })
+ cy.stub(win.URL, 'revokeObjectURL')
+ cy.stub(win.HTMLAnchorElement.prototype, 'click').callsFake(function (
+ this: HTMLAnchorElement
+ ) {
+ captured.filename = this.download
+ })
+ })
+
+ mount()
+
+ cy.then(() => {
+ currentDoc.trigger(
+ 'error',
+ new Error('forced'),
+ {},
+ 'the exact offline content'
+ )
+ })
+
+ cy.findByRole('button', { name: 'Download local version' }).click()
+
+ cy.then(() => {
+ expect(captured.filename).to.equal(currentDoc.docName)
+ return captured.blob!.text()
+ }).should('equal', 'the exact offline content')
+ })
+
+ it('falls back to OutOfSyncModal when no backup exists', function () {
+ cy.then(() => {
+ setSplitTest(true)
+ })
+ // no backup planted
+
+ mount()
+
+ cy.then(() => {
+ currentDoc.trigger('error', new Error('forced'), {}, 'content')
+ })
+
+ cy.findByRole('dialog').should('exist')
+ cy.findByText('Your offline edits couldn’t be synced').should('not.exist')
+ })
+
+ it('falls back to OutOfSyncModal when the backup holds tracked changes', function () {
+ let key: string
+ cy.then(() => {
+ setSplitTest(true)
+ key = plantBackup(CURRENT_DOC_ID, { trackChanges: true })
+ })
+
+ mount()
+
+ cy.then(() => {
+ currentDoc.trigger('error', new Error('forced'), {}, 'content')
+ })
+
+ cy.findByRole('dialog').should('exist')
+ cy.findByText('Your offline edits couldn’t be synced').should('not.exist')
+
+ // Tracked-change backups are not recovered, so they are left alone.
+ cy.then(() => {
+ expect(window.sessionStorage.getItem(key!)).to.not.equal(null)
+ })
+ })
+
+ it('shows UnableToSyncModal on the ide:unableToSyncOfflineChanges recovery event', function () {
+ let capturedEmitter: IdeEventEmitter | null = null
+
+ cy.mount(
+
+ {
+ capturedEmitter = emitter
+ }}
+ />
+
+ )
+
+ cy.then(() => {
+ capturedEmitter!.emit('ide:unableToSyncOfflineChanges', {
+ docId: CURRENT_DOC_ID,
+ editorContent: 'recovered content',
+ docName: 'recovered.tex',
+ })
+ })
+
+ cy.findByRole('dialog').within(() => {
+ cy.findByText('Your offline edits couldn’t be synced').should('exist')
+ })
+ })
+
+ it('shows the success toast on the ide:offlineChangesSynced event', function () {
+ let capturedEmitter: IdeEventEmitter | null = null
+
+ cy.mount(
+
+ {
+ capturedEmitter = emitter
+ }}
+ />
+
+
+ )
+
+ cy.then(() => {
+ capturedEmitter!.emit('ide:offlineChangesSynced', {
+ docId: CURRENT_DOC_ID,
+ })
+ })
+
+ cy.findByText('You’re back online.').should('exist')
+ })
+
+ it('falls back to OutOfSyncModal when the split test is off even if a backup exists', function () {
+ let key: string
+ cy.then(() => {
+ setSplitTest(false)
+ key = plantBackup(CURRENT_DOC_ID)
+ })
+
+ mount()
+
+ cy.then(() => {
+ currentDoc.trigger('error', new Error('forced'), {}, 'content')
+ })
+
+ cy.findByRole('dialog').should('exist')
+ cy.findByText('Your offline edits couldn’t be synced').should('not.exist')
+
+ // The disabled branch does not touch the backup.
+ cy.then(() => {
+ expect(window.sessionStorage.getItem(key!)).to.not.equal(null)
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts b/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts
index 2c60c45360..2764fcf29b 100644
--- a/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts
+++ b/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts
@@ -7,15 +7,18 @@ import {
} from '@/features/ide-react/editor/offline-doc-backup'
import { ShareJsOperation } from '@/features/ide-react/editor/types/document'
import { OTType, ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
+import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
const DOC_ID = 'doc-123'
+const OTHER_DOC_ID = 'doc-999'
const PROJECT_ID = 'project-456'
const USER_ID = 'user-789'
const KEY = `doc.offline-backup.${USER_ID}.${PROJECT_ID}.${DOC_ID}`
const FLUSH_DELAY = 2000
+const SYNCED_EVENT = 'ide:offlineChangesSynced'
class FakeShareJsDoc extends EventEmitter {
- doc_id = DOC_ID
+ doc_id: string
connection = {
state: 'ok' as 'ok' | 'disconnected' | 'stopped',
id: 'client-1',
@@ -28,6 +31,11 @@ class FakeShareJsDoc extends EventEmitter {
otType: OTType = 'sharejs-text-ot'
_doc = { inflightSubmittedIds: new Set() }
+ constructor(docId = DOC_ID) {
+ super()
+ this.doc_id = docId
+ }
+
getVersion() {
return this.version
}
@@ -65,6 +73,8 @@ function readRecord(): OfflineDocBackupRecord | null {
describe('OfflineDocBackup', function () {
let clock: sinon.SinonFakeTimers
let doc: FakeShareJsDoc
+ let eventEmitter: IdeEventEmitter
+ let syncedEvents: { docId: string }[]
const enableFlag = (enabled = true) => {
window.metaAttributesCache.set('ol-splitTestVariants', {
@@ -72,8 +82,12 @@ describe('OfflineDocBackup', function () {
})
}
- const create = () =>
- new OfflineDocBackup(doc as unknown as ShareJsDoc, PROJECT_ID)
+ const create = (target: FakeShareJsDoc = doc) =>
+ new OfflineDocBackup(
+ target as unknown as ShareJsDoc,
+ PROJECT_ID,
+ eventEmitter
+ )
beforeEach(function () {
window.metaAttributesCache = new Map()
@@ -81,6 +95,11 @@ describe('OfflineDocBackup', function () {
window.sessionStorage.clear()
clock = sinon.useFakeTimers()
doc = new FakeShareJsDoc()
+ eventEmitter = new IdeEventEmitter()
+ syncedEvents = []
+ eventEmitter.on(SYNCED_EVENT, event => {
+ syncedEvents.push(event.detail[0])
+ })
enableFlag()
})
@@ -89,8 +108,20 @@ describe('OfflineDocBackup', function () {
window.sessionStorage.clear()
})
- const goOffline = () => {
- doc.connection.state = 'disconnected'
+ const goOffline = (target: FakeShareJsDoc = doc) => {
+ target.connection.state = 'disconnected'
+ }
+
+ // Drives one offline outage on the given doc: buffer an edit, let the
+ // throttled write land, then have the server acknowledge it.
+ const editOfflineThenSave = (target: FakeShareJsDoc) => {
+ goOffline(target)
+ target.pendingOp = [{ i: 'x', p: 0 }]
+ target.trigger('change')
+ clock.tick(FLUSH_DELAY)
+ target.connection.state = 'ok'
+ target.pendingOp = null
+ target.trigger('saved')
}
it('does nothing when the split test is disabled', function () {
@@ -255,6 +286,46 @@ describe('OfflineDocBackup', function () {
expect(record?.snapshot).to.equal('server text updated')
})
+ it('emits offlineChangesSynced when the server acknowledges offline edits', function () {
+ create()
+ editOfflineThenSave(doc)
+
+ expect(syncedEvents).to.deep.equal([{ docId: DOC_ID }])
+ })
+
+ it('does not emit offlineChangesSynced when saved with no stored record', function () {
+ create()
+ doc.trigger('saved')
+
+ expect(syncedEvents).to.be.empty
+ })
+
+ it('emits offlineChangesSynced for each doc that syncs after one outage', function () {
+ const otherDoc = new FakeShareJsDoc(OTHER_DOC_ID)
+ create()
+ create(otherDoc)
+
+ goOffline()
+ goOffline(otherDoc)
+ doc.pendingOp = [{ i: 'x', p: 0 }]
+ otherDoc.pendingOp = [{ i: 'y', p: 0 }]
+ doc.trigger('change')
+ otherDoc.trigger('change')
+ clock.tick(FLUSH_DELAY)
+
+ doc.connection.state = 'ok'
+ otherDoc.connection.state = 'ok'
+ doc.pendingOp = null
+ otherDoc.pendingOp = null
+ doc.trigger('saved')
+ otherDoc.trigger('saved')
+
+ expect(syncedEvents).to.deep.equal([
+ { docId: DOC_ID },
+ { docId: OTHER_DOC_ID },
+ ])
+ })
+
it('disconnect() removes listeners but preserves the stored record', function () {
const backup = create()
goOffline()