[web] trigger post-offline sync result treatments (#35854)

* trigger `UnableToSyncModal` on refresh
* trigger `UnableToSyncModal` without refresh
* show success toast when offline edits sync
* always show out of sync modal as the final backup

GitOrigin-RevId: 3ed300fef9e6d2ff81e17868309f80ffa40e0576
This commit is contained in:
Kristina
2026-09-17 11:39:03 +00:00
committed by Copybot
parent 1ef6360fb9
commit b624adf885
10 changed files with 529 additions and 34 deletions
@@ -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": "",
@@ -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 <span>{t('youre_back_online')}</span>
}
const generators: GlobalToastGeneratorEntry[] = [
{
key: 'connection-restored',
generator: () => ({
content: <ConnectionRestoredToastContent />,
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,
},
})
)
}
@@ -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<string, GlobalToastGenerator> = new Map(
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
@@ -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<React.PropsWithChildren> = ({
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<React.PropsWithChildren> = ({
projectId,
currentDocument
)
const improvedFlakyConnections = useFeatureFlag(
'intermittent-connection-improvements'
)
const [globalEditorWatchdogManager] = useState(
() =>
@@ -576,8 +579,21 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
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<React.PropsWithChildren> = ({
showOutOfSyncModal,
setOutOfSync,
t,
improvedFlakyConnections,
projectId,
])
useEffect(() => {
const handleUnableToSync = ({
detail: [{ editorContent, docName }],
}: CustomEvent<IdeEvents['ide:unableToSyncOfflineChanges']>) => {
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(() => {
@@ -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 {
@@ -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) =>
@@ -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() {
+1
View File
@@ -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__",
@@ -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(
<EditorProviders
projectId={PROJECT_ID}
providers={{
EditorOpenDocProvider: makeEditorOpenDocProvider({
currentDocumentId: CURRENT_DOC_ID as any,
openDocName: currentDoc.docName,
currentDocument: currentDoc as any,
}),
}}
>
<OpenNewDocOnMount />
</EditorProviders>
)
}
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(
<EditorProviders
projectId={PROJECT_ID}
providers={{
EditorOpenDocProvider: makeEditorOpenDocProvider({
currentDocumentId: CURRENT_DOC_ID as any,
openDocName: currentDoc.docName,
currentDocument: currentDoc as any,
}),
}}
>
<CaptureEventEmitter
onReady={emitter => {
capturedEmitter = emitter
}}
/>
</EditorProviders>
)
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(
<EditorProviders
projectId={PROJECT_ID}
providers={{
EditorOpenDocProvider: makeEditorOpenDocProvider({
currentDocumentId: CURRENT_DOC_ID as any,
openDocName: currentDoc.docName,
currentDocument: currentDoc as any,
}),
}}
>
<CaptureEventEmitter
onReady={emitter => {
capturedEmitter = emitter
}}
/>
<GlobalToasts />
</EditorProviders>
)
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)
})
})
})
@@ -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<string>() }
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()