[web] Recover offline doc backup (#35647)

GitOrigin-RevId: 999ecc534821ab6f1bdc1bc4f7757316dab1658d
This commit is contained in:
Mathias Jakobsen
2026-09-17 11:36:07 +00:00
committed by Copybot
parent 64b6b28f69
commit 17363fcc59
6 changed files with 673 additions and 47 deletions
@@ -29,7 +29,11 @@ import {
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import getMeta from '@/utils/meta'
import OError from '@overleaf/o-error'
import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup'
import {
OfflineDocBackup,
OfflineDocBackupRecord,
} from '@/features/ide-react/editor/offline-doc-backup'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import {
HistoryOTShareDoc,
ShareLatexOTShareDoc,
@@ -526,46 +530,149 @@ export class DocumentContainer extends EventEmitter {
}
)
} else {
this.socket.emit(
'joinDoc',
const record = this.getOfflineBackupForRecovery()
if (record) {
this.recoverAtBaseline(record, callback)
} else {
this.joinFreshDoc(callback)
}
}
}
private joinFreshDoc(callback?: JoinCallback) {
this.socket.emit(
'joinDoc',
this.doc_id,
{ encodeRanges: true, supportsHistoryOT: true },
(error, docLines, version, updates, ranges, type, options) => {
if (error) {
callback?.(error)
return
}
this.createJoinedDoc(docLines, version, ranges, type, options)
callback?.()
}
)
}
private createJoinedDoc(
docLines: string[],
version: number,
ranges: RangesTracker,
type: OTType = 'sharejs-text-ot',
options: JoinDocResponseOptions = {}
) {
this.joined = true
this.canSkipLeaveDoc = options.canSkipLeaveDoc ?? false
this.doc = new ShareJsDoc(
this.doc_id,
docLines,
version,
this.socket,
this.globalEditorWatchdogManager,
this.ideEventEmitter,
type
)
if (type === 'sharejs-text-ot') {
this.decodeRanges(ranges)
}
this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
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
// and dedupes the buffered edits onto the current version, which we then
// send. Any failure falls back to a normal join, so a bad backup can never
// block opening the doc.
private recoverAtBaseline(
record: OfflineDocBackupRecord,
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)
this.doc?.clearInflightAndPendingOps()
this.doc = undefined
this.joinFreshDoc(callback)
}
try {
this.doc = new ShareJsDoc(
this.doc_id,
{
encodeRanges: true,
supportsHistoryOT: true,
},
(
error,
docLines,
version,
updates,
ranges,
type: OTType = 'sharejs-text-ot',
options: JoinDocResponseOptions = {}
) => {
if (error) {
callback?.(error)
return
}
[],
record.version,
this.socket,
this.globalEditorWatchdogManager,
this.ideEventEmitter,
'sharejs-text-ot'
)
this.doc.restoreFromOfflineBackup(record)
this.ranges = new RangesTracker()
this.bindToShareJsDocEvents()
} catch (error) {
loadWithoutRecovery(error)
return
}
this.socket.emit(
'joinDoc',
this.doc_id,
record.version,
{ encodeRanges: true, supportsHistoryOT: true },
(
error,
docLines,
version,
updates,
ranges,
type = 'sharejs-text-ot',
options: JoinDocResponseOptions = {}
) => {
// Versioned join failed (e.g. the baseline aged out of the server's op
// window); we can't catch up, so load normally.
if (error) {
loadWithoutRecovery(error)
return
}
// The backup only holds sharejs-text-ot state; if the doc has since
// migrated (e.g. to history-ot) we can't replay onto it.
if (type !== 'sharejs-text-ot') {
loadWithoutRecovery(new Error(`unexpected doc type: ${type}`))
return
}
try {
this.joined = true
this.canSkipLeaveDoc = options.canSkipLeaveDoc ?? false
this.doc = new ShareJsDoc(
this.doc_id,
docLines,
version,
this.socket,
this.globalEditorWatchdogManager,
this.ideEventEmitter,
type
)
if (type === 'sharejs-text-ot') {
this.decodeRanges(ranges)
}
this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
this.bindToShareJsDocEvents()
callback?.()
// rethrow errors to catch them here and revert to fresh load
this.doc!.catchUp(updates, { rethrow: true })
this.decodeRanges(ranges)
this.catchUpRanges(ranges?.changes, ranges?.comments)
this.doc!.sendRecoveredOps()
} catch (err) {
loadWithoutRecovery(err)
return
}
)
}
callback?.()
}
)
}
private decodeRanges(ranges: RangesTracker) {
@@ -633,7 +740,7 @@ export class DocumentContainer extends EventEmitter {
this.detachDoc(this.doc_id, this)
this.offlineBackup?.destroy()
this.offlineBackup?.disconnect()
this.unBindFromEditorEvents()
this.unBindFromSocketEvents()
@@ -46,6 +46,10 @@ export class OfflineDocBackup {
)
}
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.
@@ -152,7 +156,7 @@ export class OfflineDocBackup {
customSessionStorage.removeItem(this.key)
}
destroy() {
disconnect() {
if (!this.enabled) {
return
}
@@ -30,6 +30,7 @@ import type {
} from 'overleaf-editor-core/lib/types'
import { HistoryOTShareDoc } from '../../../../../types/share-doc'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { OfflineDocBackupRecord } from './offline-doc-backup'
const intermittentConnectionImprovementsEnabled = isSplitTestEnabled(
'intermittent-connection-improvements'
@@ -275,7 +276,7 @@ export class ShareJsDoc extends EventEmitter {
// FIXME: This is the original method. Switch back to this when redis
// issues are resolved.
processUpdateFromServer(message: Message) {
processUpdateFromServer(message: Message, { rethrow = false } = {}) {
try {
if (this.type === 'history-ot' && message.op != null) {
const shareDoc = this._doc as HistoryOTShareDoc
@@ -304,6 +305,9 @@ export class ShareJsDoc extends EventEmitter {
} catch (error) {
// Version mismatches are thrown as errors
debugConsole.log(error)
if (rethrow) {
throw error
}
this.handleError(error)
return error // return the error for queue handling
}
@@ -313,11 +317,11 @@ export class ShareJsDoc extends EventEmitter {
}
}
catchUp(updates: Message[]) {
catchUp(updates: Message[], { rethrow = false } = {}) {
return updates.map(update => {
update.v = this._doc.version
update.doc = this.doc_id
return this.processUpdateFromServer(update)
return this.processUpdateFromServer(update, { rethrow })
})
}
@@ -351,6 +355,50 @@ export class ShareJsDoc extends EventEmitter {
return this._doc.flush()
}
// Rebuild the client state captured in an offline backup: the server-acked
// baseline snapshot plus the ops that were buffered on top of it. Recovery
// constructs a doc at the baseline version, restores this, then catches the
// doc up to the current server version.
restoreFromOfflineBackup(backup: OfflineDocBackupRecord) {
// The snapshot must be the local view (baseline with the buffered ops
// applied), matching ShareJS's invariant that snapshot = acked version +
// inflight + pending. Seeding only the baseline would show the server text
// without the recovered edits.
let snapshot = backup.snapshot
if (backup.inflightOp) {
snapshot = sharejs.types.text.apply(snapshot, backup.inflightOp)
}
if (backup.pendingOp) {
snapshot = sharejs.types.text.apply(snapshot, backup.pendingOp)
}
this._doc.snapshot = snapshot
this._doc.inflightOp = backup.inflightOp
this._doc.pendingOp = backup.pendingOp
this._doc.inflightSubmittedIds = [...backup.inflightSubmittedIds]
}
// Push the buffered ops of a recovered doc to the server. A doc rebuilt from
// a backup never went through submitOp, so no retry timer was armed: resend a
// surviving inflight op explicitly (deduped server-side via
// inflightSubmittedIds, and re-arming the timer via connection.send); a lone
// pending op just needs a flush.
sendRecoveredOps() {
if (this._doc.inflightOp?.length === 0) {
this._doc.inflightOp = null
this._doc.inflightSubmittedIds = []
}
if (this._doc.inflightOp) {
this.connection.send({
doc: this.doc_id,
op: this._doc.inflightOp,
v: this._doc.version,
dupIfSource: [...this._doc.inflightSubmittedIds],
})
} else {
this.flushPendingOps()
}
}
updateConnectionState(state: ShareJsConnectionState) {
debugConsole.log(`[updateConnectionState] Setting state to ${state}`)
this.connection.state = state