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__ licenses to your plan giving you a total of <1>__total__ 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()