mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
[web] Start saving offline backups after 10 seconds GitOrigin-RevId: 4101598f46b6f71dc8bf10a31cbec9110216ed7a
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
import customSessionStorage from '@/infrastructure/session-storage'
|
|
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
|
|
const KEY_PREFIX = 'doc.offline-backup.'
|
|
const STALL_AFTER_SECONDS = 10
|
|
|
|
type Baseline = {
|
|
version: number
|
|
snapshot: string | null
|
|
}
|
|
|
|
export type OfflineDocBackupRecord = {
|
|
docId: string
|
|
projectId: string
|
|
version: number
|
|
snapshot: string
|
|
inflightOp: ShareJsOperation | null
|
|
pendingOp: ShareJsOperation | null
|
|
trackChanges: boolean
|
|
updatedAt: number
|
|
inflightSubmittedIds: string[]
|
|
}
|
|
|
|
/**
|
|
* Periodically persists, per open document, the baseline snapshot (V_start)
|
|
* plus local edits to sessionStorage while the client is offline
|
|
*/
|
|
export class OfflineDocBackup {
|
|
private readonly enabled: boolean
|
|
private readonly key: string
|
|
private baseline: Baseline | null = null
|
|
private throttleTimer: number | null = null
|
|
|
|
static buildKey(projectId: string, docId: string): string {
|
|
const userId = getMeta('ol-user_id') ?? 'anonymous'
|
|
return `${KEY_PREFIX}${userId}.${projectId}.${docId}`
|
|
}
|
|
|
|
static read(projectId: string, docId: string): OfflineDocBackupRecord | null {
|
|
return customSessionStorage.getItem(
|
|
OfflineDocBackup.buildKey(projectId, docId)
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return OfflineDocBackup.read(projectId, docId)
|
|
}
|
|
|
|
static remove(projectId: string, docId: string): void {
|
|
customSessionStorage.removeItem(OfflineDocBackup.buildKey(projectId, docId))
|
|
}
|
|
|
|
// Sweep every user's records. sessionStorage is per-tab and can't be reached
|
|
// from the tab that handles logout for any other tab, so we clear the whole
|
|
// prefix here rather than scoping to the current user.
|
|
static clearAll(): void {
|
|
customSessionStorage.removeByPrefix(KEY_PREFIX)
|
|
}
|
|
|
|
constructor(
|
|
private readonly doc: ShareJsDoc,
|
|
private readonly projectId: string,
|
|
private readonly ideEventEmitter: IdeEventEmitter
|
|
) {
|
|
this.enabled = isSplitTestEnabled('intermittent-connection-improvements')
|
|
this.key = OfflineDocBackup.buildKey(projectId, doc.doc_id)
|
|
|
|
if (!this.enabled) {
|
|
return
|
|
}
|
|
|
|
this.refreshBaseline()
|
|
|
|
this.onSaved = this.onSaved.bind(this)
|
|
this.onDocChange = this.onDocChange.bind(this)
|
|
|
|
this.doc.on(`saved.${EVENT_NAMESPACE}`, this.onSaved)
|
|
this.doc.on(`change.${EVENT_NAMESPACE}`, this.onDocChange)
|
|
this.doc.on(`acknowledge.${EVENT_NAMESPACE}`, this.onDocChange)
|
|
this.doc.on(
|
|
`flipped_pending_to_inflight.${EVENT_NAMESPACE}`,
|
|
this.onDocChange
|
|
)
|
|
}
|
|
|
|
private isOffline() {
|
|
return this.doc.connection.state !== 'ok'
|
|
}
|
|
|
|
private isSavingStalled() {
|
|
const oldestOpCreatedAt =
|
|
this.doc.getInflightOpCreatedAt() ?? this.doc.getPendingOpCreatedAt()
|
|
if (!oldestOpCreatedAt) {
|
|
return false
|
|
}
|
|
const unsavedSeconds = (performance.now() - oldestOpCreatedAt) / 1000
|
|
return unsavedSeconds > STALL_AFTER_SECONDS
|
|
}
|
|
|
|
private shouldBackup() {
|
|
return this.isOffline() || this.isSavingStalled()
|
|
}
|
|
|
|
private refreshBaseline() {
|
|
// baseline is the clean server state, buffered ops would
|
|
// bake local edits into V_start and corrupt recovery
|
|
if (this.doc.hasBufferedOps()) {
|
|
return
|
|
}
|
|
this.baseline = {
|
|
version: this.doc.getVersion(),
|
|
snapshot: this.serializeSnapshot(),
|
|
}
|
|
}
|
|
|
|
private serializeSnapshot(): string | null {
|
|
// TODO: support history-ot
|
|
if (this.doc.getType() !== 'sharejs-text-ot') {
|
|
return null
|
|
}
|
|
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() {
|
|
// While online, keep the baseline in step with the server so V_start
|
|
// reflects remote ops applied before we go offline. refreshBaseline no-ops
|
|
// once there are buffered ops, freezing the baseline at the point we start
|
|
// diverging offline.
|
|
if (!this.shouldBackup()) {
|
|
this.refreshBaseline()
|
|
return
|
|
}
|
|
if (!this.doc.hasBufferedOps()) {
|
|
return
|
|
}
|
|
if (this.throttleTimer !== null) {
|
|
return
|
|
}
|
|
this.throttleTimer = window.setTimeout(() => {
|
|
this.throttleTimer = null
|
|
if (this.shouldBackup() && this.doc.hasBufferedOps()) {
|
|
this.write()
|
|
}
|
|
}, OFFLINE_BACKUP_INTERVAL_MS)
|
|
}
|
|
|
|
private write() {
|
|
if (!this.baseline || this.baseline.snapshot === null) {
|
|
return
|
|
}
|
|
const record: OfflineDocBackupRecord = {
|
|
docId: this.doc.doc_id,
|
|
projectId: this.projectId,
|
|
version: this.baseline.version,
|
|
snapshot: this.baseline.snapshot,
|
|
inflightOp: this.doc.getInflightOp(),
|
|
pendingOp: this.doc.getPendingOp(),
|
|
// A toggle can't take effect while we are offline: syncTrackChangesState
|
|
// defers it until the buffer has drained, so this is still the state every
|
|
// buffered op was made under.
|
|
trackChanges: this.doc.track_changes,
|
|
updatedAt: Date.now(),
|
|
inflightSubmittedIds: Array.from(this.doc.getInflightSubmittedIds()),
|
|
}
|
|
customSessionStorage.setItem(this.key, record)
|
|
}
|
|
|
|
private clear() {
|
|
customSessionStorage.removeItem(this.key)
|
|
}
|
|
|
|
disconnect() {
|
|
if (!this.enabled) {
|
|
return
|
|
}
|
|
if (this.throttleTimer !== null) {
|
|
window.clearTimeout(this.throttleTimer)
|
|
this.throttleTimer = null
|
|
}
|
|
this.doc.off(`saved.${EVENT_NAMESPACE}`)
|
|
this.doc.off(`change.${EVENT_NAMESPACE}`)
|
|
this.doc.off(`acknowledge.${EVENT_NAMESPACE}`)
|
|
this.doc.off(`flipped_pending_to_inflight.${EVENT_NAMESPACE}`)
|
|
|
|
// Don't clear the stored record here. This also runs when the doc is torn
|
|
// down after a fatal timeout / out-of-sync error, which is when we need the
|
|
// backup to survive so it can be recovered on reload. Only `saved` clears
|
|
// it, once the server confirms our edits are stored.
|
|
}
|
|
}
|