mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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:
@@ -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() {
|
||||
|
||||
@@ -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__",
|
||||
|
||||
+311
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
+76
-5
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user