From 7526630d00864ecaf5e34e073a06bb366dd8751f Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 14 Jul 2026 09:29:51 +0200 Subject: [PATCH 01/15] fix(autosave): every time the typing stops Debounce will delay the execution of the function every time it is called. So autosave was waiting until no updates occured for 30 seconds. That's a long time and does not feel responsive. Try to autosave every time the user stops typing for at least one second. Signed-off-by: Max --- src/services/SaveService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 7d003975481..7652e950b8c 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -17,7 +17,7 @@ import { ERROR_TYPE, type SyncService } from './SyncService' * * @type {number} time in ms */ -const AUTOSAVE_INTERVAL = 30000 +const AUTOSAVE_DEBOUNCE = 1000 class SaveService { connection: ShallowRef @@ -41,7 +41,7 @@ class SaveService { this.syncService = syncService this.serialize = serialize this.getDocumentState = getDocumentState - this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_INTERVAL) + this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_DEBOUNCE) this.syncService.bus.on('close', () => { this.autosave.clear() }) @@ -111,6 +111,7 @@ class SaveService { } _autosave() { + logger.debug('_autosave') return this.save({ manualSave: false }).catch((error) => { logger.error('Failed to autosave document.', { error }) // retry in 30 seconds From 63a825567038d20a49691068368369cfbb4cbd7d Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 25 Jul 2026 22:06:33 +0200 Subject: [PATCH 02/15] chore(refactor): document tracking into provideSyncService Also removed the document attribute from the `sync` event load. It is already included in the `change` event load. Signed-off-by: Max --- src/components/Editor.vue | 14 ++++---------- src/composables/useSyncService.ts | 20 +++++++++++++++++++- src/services/PollingBackend.ts | 12 ++++++------ src/services/SyncService.ts | 3 --- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 356ce1b4c91..03578980815 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -260,7 +260,7 @@ export default defineComponent({ getBaseVersionEtag, setBaseVersionEtag, ) - const { syncService } = provideSyncService(connection, openConnection) + const { document, syncService } = provideSyncService(connection, openConnection) const extensions = [ Autofocus.configure({ fileId: props.fileId }), Collaboration.configure({ document: ydoc }), @@ -315,6 +315,7 @@ export default defineComponent({ clearIndexedDb, connection, dirty, + document, editor, editorReady, el, @@ -343,7 +344,6 @@ export default defineComponent({ return { IDLE_TIMEOUT, - document: null, fileNode: null, idle: false, @@ -573,8 +573,7 @@ export default defineComponent({ this.idle = false }, - onOpened({ document, session, content, documentState, readOnly }) { - this.document = document + onOpened({ session, content, documentState, readOnly }) { this.readOnly = readOnly this.editMode = !readOnly && !this.openReadOnlyEnabled this.hasConnectionIssue = false @@ -628,9 +627,7 @@ export default defineComponent({ this.updateUser(session) }, - onChange({ document }) { - this.document = document - + onChange() { this.syncError = null this.setEditable(this.editMode) }, @@ -664,9 +661,6 @@ export default defineComponent({ this.$nextTick(() => { this.emit('sync-service:sync') }) - if (document) { - this.document = document - } }, onError({ type, data }) { diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 08b4483d1b3..77fd4e9a001 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -23,7 +23,25 @@ export function provideSyncService( openConnection, }) provide(syncServiceKey, syncService) - return { syncService } + + const document = ref() + /** + * Update the document ref based on the event provided + * + * @param event that triggered the update + * @param event.document latest state of the document + */ + function updateDocument({ document: current }: { document: Document }) { + document.value = current + } + syncService.bus.on('opened', updateDocument) + syncService.bus.on('change', updateDocument) + onUnmounted(() => { + syncService.bus.off('opened', updateDocument) + syncService.bus.off('change', updateDocument) + }) + + return { document, syncService } } export const useSyncService = () => { diff --git a/src/services/PollingBackend.ts b/src/services/PollingBackend.ts index e37ce7acea8..0bb049878a5 100644 --- a/src/services/PollingBackend.ts +++ b/src/services/PollingBackend.ts @@ -143,23 +143,23 @@ class PollingBackend { } _handleResponse({ data }: { data: PollData }) { - const { document, sessions } = data + const { document, readOnly, sessions, steps } = data this.#fetchRetryCounter = 0 - if (data.readOnly !== undefined && data.readOnly !== this.#readOnly) { - this.#readOnly = data.readOnly + if (readOnly !== undefined && readOnly !== this.#readOnly) { + this.#readOnly = readOnly this.#syncService.bus.emit('permissionChange', { readOnly: this.#readOnly, }) - if (data.readOnly) { + if (readOnly) { this.maximumReadOnlyTimer() } } this.#syncService.bus.emit('change', { document, sessions }) - this.#syncService.receiveSteps(data) + this.#syncService.receiveSteps({ sessions, steps }) - if (data.steps.length === 0) { + if (steps.length === 0) { if (!this.#initialLoadingFinished) { this.#initialLoadingFinished = true } diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 586a063604a..15ad3bdb0fb 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -294,17 +294,14 @@ class SyncService { receiveSteps({ steps, - document, sessions = [], }: { steps: Step[] - document?: object sessions?: Session[] }) { const versionAfter = Math.max(this.version, ...steps.map((s) => s.version)) this.bus.emit('sync', { steps: [...awarenessSteps(sessions), ...steps], - document, }) if (this.version < versionAfter) { // Steps up to version where emitted but it looks like they were not processed. From 8ec7e82f491fe69e2faf9e68909e204928ce798c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 25 Jul 2026 22:45:23 +0200 Subject: [PATCH 03/15] fix(autosave): only save when server is ready The server will only accept autosaves every 10 seconds. `document` contains the last saved timestamp. Compute the time to autosave next. Add a small random delay (up to 3 seconds) to avoid all connected clients from saving at the same time. Signed-off-by: Max --- src/apis/save.ts | 2 +- src/composables/useSaveService.ts | 2 ++ src/composables/useSyncService.ts | 2 ++ src/services/SaveService.ts | 18 +++++++++++++++--- src/services/SyncService.ts | 2 +- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/apis/save.ts b/src/apis/save.ts index 45e87f910f7..399726da38f 100644 --- a/src/apis/save.ts +++ b/src/apis/save.ts @@ -19,7 +19,7 @@ interface SaveData { } interface SaveResponse { - data: Document + data: { document: Document } } /** diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 8ef431d8244..6d93a3c3f75 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -14,12 +14,14 @@ const saveServiceKey = Symbol('text:save') as InjectionKey export const provideSaveService = ( connection: ShallowRef, + document: Ref, syncService: SyncService, serialize: () => string, ydoc: Doc, ) => { const saveService = new SaveService({ connection, + document, syncService, serialize, getDocumentState: () => getDocumentState(ydoc), diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 77fd4e9a001..4ac02b97183 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -36,9 +36,11 @@ export function provideSyncService( } syncService.bus.on('opened', updateDocument) syncService.bus.on('change', updateDocument) + syncService.bus.on('save', updateDocument) onUnmounted(() => { syncService.bus.off('opened', updateDocument) syncService.bus.off('change', updateDocument) + syncService.bus.off('save', updateDocument) }) return { document, syncService } diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 7652e950b8c..58ab94b6260 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -21,6 +21,7 @@ const AUTOSAVE_DEBOUNCE = 1000 class SaveService { connection: ShallowRef + document: Ref syncService serialize getDocumentState @@ -28,20 +29,23 @@ class SaveService { constructor({ connection, + document, syncService, serialize, getDocumentState, }: { connection: ShallowRef + document: Ref syncService: SyncService serialize: () => string getDocumentState: () => string }) { this.connection = connection + this.document = document this.syncService = syncService this.serialize = serialize this.getDocumentState = getDocumentState - this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_DEBOUNCE) + this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_DEBOUNCE * 1000) this.syncService.bus.on('close', () => { this.autosave.clear() }) @@ -69,7 +73,6 @@ class SaveService { force, manualSave, }) - this.emit('stateChange', { dirty: false }) logger.debug('[SaveService] saved', { response }) this.emit('save', response.data) this.autosave.clear() @@ -111,7 +114,16 @@ class SaveService { } _autosave() { - logger.debug('_autosave') + const lastSave = this.document.value?.lastSavedVersionTime ?? 0 + const now = Date.now() / 1000 + // Server won't accept autosaves yet + if (now < lastSave + SERVER_AUTOSAVE_INTERVAL) { + logger.debug('Not autosaving as last save is recent', { lastSave, now }) + const nextSave = lastSave + SERVER_AUTOSAVE_INTERVAL + Math.random() * MAX_RANDOM_AUTOSAVE_DELAY + setTimeout(() => this.autosave(), (nextSave - now) * 1000) + return + } + logger.debug('Autosaving') return this.save({ manualSave: false }).catch((error) => { logger.error('Failed to autosave document.', { error }) // retry in 30 seconds diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 15ad3bdb0fb..3ec6d6f07f1 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -122,7 +122,7 @@ export declare type EventTypes = { change: { sessions: Session[]; document: Document } /* Emitted after successful save */ - save: object + save: { document: Document } /* Emitted once a document becomes idle */ idle: void From 01ba6b0f75bc068d99fda562e8092bf5a0b56d16 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Jul 2026 19:01:16 +0200 Subject: [PATCH 04/15] fix(autosave): better dirty tracking with versions Keep track of the version that has our changes and compare it to the last saved version on the server. This allows fixing two scenarios: * Server response happily to save but does not actually save. The server will only save new versions every 10 seconds. If the latest save just happened it will still respond with 200 but list the outdated version in the response. Comparing the versions shows that our changes have not been saved yet. * Other user already saved the file. our changes. So far dirty would stay true until WE save our changes. Comparing the versions also shows the file was saved when it was saved by someone else. Signed-off-by: Max --- src/components/Editor.vue | 9 ++++++++- src/composables/useSyncService.ts | 26 +++++++++++++++++++++++--- src/services/SyncService.ts | 8 +++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 03578980815..b6ce0090e8b 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -260,7 +260,7 @@ export default defineComponent({ getBaseVersionEtag, setBaseVersionEtag, ) - const { document, syncService } = provideSyncService(connection, openConnection) + const { document, syncService } = provideSyncService(connection, openConnection, setDirty) const extensions = [ Autofocus.configure({ fileId: props.fileId }), Collaboration.configure({ document: ydoc }), @@ -297,6 +297,7 @@ export default defineComponent({ const { saveService } = provideSaveService( connection, + document, syncService, serialize, ydoc, @@ -550,6 +551,7 @@ export default defineComponent({ bus.on('idle', this.onIdle) bus.on('save', this.onSave) bus.on('permissionChange', this.onPermissionChange) + bus.on('changesPushed', this.onChangesPushed) }, unlistenSyncServiceEvents() { @@ -562,6 +564,7 @@ export default defineComponent({ bus.off('idle', this.onIdle) bus.off('save', this.onSave) bus.off('permissionChange', this.onPermissionChange) + bus.off('changesPushed', this.onChangesPushed) }, reconnect() { @@ -770,6 +773,10 @@ export default defineComponent({ } }, + onChangesPushed() { + this.saveService.autosave() + }, + onFocus() { this.emit('focus') }, diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 4ac02b97183..958730cab74 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -13,10 +13,12 @@ const syncServiceKey = Symbol('text:sync') as InjectionKey * Define a sync service and provide it to child components * @param connection Connection to the text api. * @param openConnection Function to open the connection. + * @param setDirty to udpate the dirty state. */ export function provideSyncService( connection: ShallowRef, openConnection: () => Promise, + setDirty: (val: boolean) => Promise, ) { const syncService = new SyncService({ connection, @@ -31,8 +33,8 @@ export function provideSyncService( * @param event that triggered the update * @param event.document latest state of the document */ - function updateDocument({ document: current }: { document: Document }) { - document.value = current + function updateDocument(event: { document: Document }) { + document.value = event.document } syncService.bus.on('opened', updateDocument) syncService.bus.on('change', updateDocument) @@ -43,7 +45,25 @@ export function provideSyncService( syncService.bus.off('save', updateDocument) }) - return { document, syncService } + const versionWithChanges = ref(0) + /** + * Update the tracked version based on the one in the event + * + * @param event that triggered the update + * @param event.version with changes pushed to the server + */ + function updateVersionWithChanges(event: { version: number }) { + versionWithChanges.value = Math.max(event.version, versionWithChanges.value) + } + syncService.bus.on('changesPushed', updateVersionWithChanges) + onUnmounted(() => { + syncService.bus.off('changesPushed', updateVersionWithChanges) + }) + + const dirty = computed(() => (document.value?.lastSavedVersion ?? 0) < versionWithChanges.value) + watch(dirty, setDirty) + + return { dirty, document, syncService } } export const useSyncService = () => { diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 3ec6d6f07f1..d4eae718d76 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -232,9 +232,7 @@ class SyncService { this.#sending = true clearInterval(this.#sendIntervalId) this.#sendIntervalId = undefined - if (this.#outbox.hasUpdate) { - this.bus.emit('stateChange', { dirty: true }) - } + const hadUpdate = this.#outbox.hasUpdate if (!this.hasActiveConnection()) { return } @@ -257,6 +255,10 @@ class SyncService { this.#sending = false if (steps?.length > 0) { this.receiveSteps({ steps }) + if (hadUpdate) { + // this.version has been increased in receiveSteps + this.bus.emit('changesPushed', { version: this.version }) + } } }) .catch((err) => { From fc31b773867994d8129e2ba8cb858787cb0bb4d1 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Jul 2026 06:50:17 +0200 Subject: [PATCH 05/15] chore(refactor): Save service with its own bus Signed-off-by: Max --- src/components/Editor.vue | 18 +++++-------- src/composables/useSaveService.ts | 45 +++++++++++++++++++++++++++++-- src/composables/useSyncService.ts | 41 +--------------------------- src/services/SaveService.ts | 19 ++++++++----- src/services/SyncService.ts | 3 --- 5 files changed, 64 insertions(+), 62 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index b6ce0090e8b..e9ff9fda62b 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -260,7 +260,7 @@ export default defineComponent({ getBaseVersionEtag, setBaseVersionEtag, ) - const { document, syncService } = provideSyncService(connection, openConnection, setDirty) + const { syncService } = provideSyncService(connection, openConnection) const extensions = [ Autofocus.configure({ fileId: props.fileId }), Collaboration.configure({ document: ydoc }), @@ -295,12 +295,12 @@ export default defineComponent({ ) : () => serializePlainText(editor.state.doc) - const { saveService } = provideSaveService( + const { document, saveService } = provideSaveService( connection, - document, syncService, serialize, ydoc, + setDirty, ) const syncProvider = shallowRef(null) @@ -549,9 +549,9 @@ export default defineComponent({ bus.on('error', this.onError) bus.on('stateChange', this.onStateChange) bus.on('idle', this.onIdle) - bus.on('save', this.onSave) bus.on('permissionChange', this.onPermissionChange) - bus.on('changesPushed', this.onChangesPushed) + this.saveService.bus.on('error', this.onError) + this.saveService.bus.on('save', this.onSave) }, unlistenSyncServiceEvents() { @@ -562,9 +562,9 @@ export default defineComponent({ bus.off('error', this.onError) bus.off('stateChange', this.onStateChange) bus.off('idle', this.onIdle) - bus.off('save', this.onSave) bus.off('permissionChange', this.onPermissionChange) - bus.off('changesPushed', this.onChangesPushed) + this.saveService.bus.off('error', this.onError) + this.saveService.bus.off('save', this.onSave) }, reconnect() { @@ -773,10 +773,6 @@ export default defineComponent({ } }, - onChangesPushed() { - this.saveService.autosave() - }, - onFocus() { this.emit('focus') }, diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 6d93a3c3f75..7e8feedd1ec 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -14,7 +14,6 @@ const saveServiceKey = Symbol('text:save') as InjectionKey export const provideSaveService = ( connection: ShallowRef, - document: Ref, syncService: SyncService, serialize: () => string, ydoc: Doc, @@ -26,8 +25,50 @@ export const provideSaveService = ( serialize, getDocumentState: () => getDocumentState(ydoc), }) + + syncService.bus.on('changesPushed', saveService.autosave) + onUnmounted(() => { + syncService.bus.off('changesPushed', saveService.autosave) + }) + + /** + * Update the document ref based on the event provided + * + * @param event that triggered the update + * @param event.document latest state of the document + */ + function updateDocument(event: { document: Document }) { + document.value = event.document + } + syncService.bus.on('opened', updateDocument) + syncService.bus.on('change', updateDocument) + saveService.bus.on('save', updateDocument) + onUnmounted(() => { + syncService.bus.off('opened', updateDocument) + syncService.bus.off('change', updateDocument) + saveService.bus.off('save', updateDocument) + }) + + const versionWithChanges = ref(0) + /** + * Update the tracked version based on the one in the event + * + * @param event that triggered the update + * @param event.version with changes pushed to the server + */ + function updateVersionWithChanges(event: { version: number }) { + versionWithChanges.value = Math.max(event.version, versionWithChanges.value) + } + syncService.bus.on('changesPushed', updateVersionWithChanges) + onUnmounted(() => { + syncService.bus.off('changesPushed', updateVersionWithChanges) + }) + + const dirty = computed(() => (document.value?.lastSavedVersion ?? 0) < versionWithChanges.value) + watch(dirty, setDirty) + provide(saveServiceKey, saveService) - return { saveService } + return { document, saveService } } export const useSaveService = () => { diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 958730cab74..c6038b13a2d 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -13,12 +13,10 @@ const syncServiceKey = Symbol('text:sync') as InjectionKey * Define a sync service and provide it to child components * @param connection Connection to the text api. * @param openConnection Function to open the connection. - * @param setDirty to udpate the dirty state. */ export function provideSyncService( connection: ShallowRef, openConnection: () => Promise, - setDirty: (val: boolean) => Promise, ) { const syncService = new SyncService({ connection, @@ -26,44 +24,7 @@ export function provideSyncService( }) provide(syncServiceKey, syncService) - const document = ref() - /** - * Update the document ref based on the event provided - * - * @param event that triggered the update - * @param event.document latest state of the document - */ - function updateDocument(event: { document: Document }) { - document.value = event.document - } - syncService.bus.on('opened', updateDocument) - syncService.bus.on('change', updateDocument) - syncService.bus.on('save', updateDocument) - onUnmounted(() => { - syncService.bus.off('opened', updateDocument) - syncService.bus.off('change', updateDocument) - syncService.bus.off('save', updateDocument) - }) - - const versionWithChanges = ref(0) - /** - * Update the tracked version based on the one in the event - * - * @param event that triggered the update - * @param event.version with changes pushed to the server - */ - function updateVersionWithChanges(event: { version: number }) { - versionWithChanges.value = Math.max(event.version, versionWithChanges.value) - } - syncService.bus.on('changesPushed', updateVersionWithChanges) - onUnmounted(() => { - syncService.bus.off('changesPushed', updateVersionWithChanges) - }) - - const dirty = computed(() => (document.value?.lastSavedVersion ?? 0) < versionWithChanges.value) - watch(dirty, setDirty) - - return { dirty, document, syncService } + return { syncService } } export const useSyncService = () => { diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 58ab94b6260..7bf22e7df6c 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -19,7 +19,18 @@ import { ERROR_TYPE, type SyncService } from './SyncService' */ const AUTOSAVE_DEBOUNCE = 1000 +type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE] + +export declare type EventTypes = { + /* error */ + error: { type: ErrorType, data?: object } + + /* Emitted after successful save */ + save: { document: Document } +} + class SaveService { + bus = mitt() connection: ShallowRef document: Ref syncService @@ -55,10 +66,6 @@ class SaveService { return this.syncService.version } - get emit() { - return this.syncService.bus.emit - } - async save({ force = false, manualSave = true } = {}) { logger.debug('[SaveService] saving', { force, manualSave }) if (!this.connection.value) { @@ -74,7 +81,7 @@ class SaveService { manualSave, }) logger.debug('[SaveService] saved', { response }) - this.emit('save', response.data) + this.bus.emit('save', response.data) this.autosave.clear() } catch (e) { logger.error('Failed to save document.', { error: e }) @@ -86,7 +93,7 @@ class SaveService { return } if (response?.status === 412) { - this.emit('error', { + this.bus.emit('error', { type: ERROR_TYPE.LOAD_ERROR, data: response, }) diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index d4eae718d76..be0b4510fd8 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -121,9 +121,6 @@ export declare type EventTypes = { /* Events for session and document meta data */ change: { sessions: Session[]; document: Document } - /* Emitted after successful save */ - save: { document: Document } - /* Emitted once a document becomes idle */ idle: void From ae2316b068274d3c31955e5609331d7a21b152f5 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Jul 2026 07:15:29 +0200 Subject: [PATCH 06/15] chore(refactor): separate SaveService from SyncService and ydoc * `getSaveData` now prepares the data to send to the server. * `provideSaveService` now handles all the sync service events calling functions on `saveService` where needed. Signed-off-by: Max --- src/apis/save.ts | 9 ++++++--- src/composables/useSaveService.ts | 6 +++--- src/services/SaveService.ts | 29 +++++++---------------------- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/apis/save.ts b/src/apis/save.ts index 399726da38f..6b9cd4944ee 100644 --- a/src/apis/save.ts +++ b/src/apis/save.ts @@ -10,10 +10,13 @@ import { unref, type ShallowRef } from 'vue' import type { Connection } from '../composables/useConnection' import type { Document } from '../services/SyncService' -interface SaveData { +export interface SaveData { version: number autosaveContent: string documentState: string +} + +export interface SaveOptions { force: boolean manualSave: boolean } @@ -29,7 +32,7 @@ interface SaveResponse { */ export function save( connection: ShallowRef | Connection, - data: SaveData, + data: SaveData & SaveOptions, ): Promise { const con = unref(connection) const pub = con.shareToken ? '/public' : '' @@ -57,7 +60,7 @@ export function save( */ export function saveViaSendBeacon( connection: Connection, - data: Omit, + data: SaveData, ): boolean { const con = unref(connection) const pub = con.shareToken ? '/public' : '' diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 7e8feedd1ec..5e86784cf04 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -21,14 +21,14 @@ export const provideSaveService = ( const saveService = new SaveService({ connection, document, - syncService, - serialize, - getDocumentState: () => getDocumentState(ydoc), + getSaveData, }) syncService.bus.on('changesPushed', saveService.autosave) + syncService.bus.on('close', saveService.clear) onUnmounted(() => { syncService.bus.off('changesPushed', saveService.autosave) + syncService.bus.off('close', saveService.clear) }) /** diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 7bf22e7df6c..7cd9ce57929 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -33,37 +33,24 @@ class SaveService { bus = mitt() connection: ShallowRef document: Ref - syncService - serialize - getDocumentState + getSaveData autosave + clear constructor({ connection, document, - syncService, - serialize, - getDocumentState, + getSaveData, }: { connection: ShallowRef document: Ref - syncService: SyncService - serialize: () => string - getDocumentState: () => string + getSaveData: () => SaveData }) { this.connection = connection this.document = document - this.syncService = syncService - this.serialize = serialize - this.getDocumentState = getDocumentState + this.getSaveData = getSaveData this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_DEBOUNCE * 1000) - this.syncService.bus.on('close', () => { - this.autosave.clear() - }) - } - - get version() { - return this.syncService.version + this.clear = this.autosave.clear.bind(this.autosave) } async save({ force = false, manualSave = true } = {}) { @@ -74,9 +61,7 @@ class SaveService { } try { const response = await save(this.connection.value, { - version: this.version, - autosaveContent: this.serialize(), - documentState: this.getDocumentState(), + ...this.getSaveData(), force, manualSave, }) From 7ae76ede7ac953d9f8662361d786d450f9195803 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Jul 2026 07:55:07 +0200 Subject: [PATCH 07/15] fix(autosave): exponential delay for retries on error Signed-off-by: Max --- src/services/SaveService.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 7cd9ce57929..6d97063fdea 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -33,6 +33,7 @@ class SaveService { bus = mitt() connection: ShallowRef document: Ref + autosaveErrorCount = 0 getSaveData autosave clear @@ -112,15 +113,21 @@ class SaveService { if (now < lastSave + SERVER_AUTOSAVE_INTERVAL) { logger.debug('Not autosaving as last save is recent', { lastSave, now }) const nextSave = lastSave + SERVER_AUTOSAVE_INTERVAL + Math.random() * MAX_RANDOM_AUTOSAVE_DELAY - setTimeout(() => this.autosave(), (nextSave - now) * 1000) + setTimeout(this.autosave, (nextSave - now) * 1000) return } logger.debug('Autosaving') - return this.save({ manualSave: false }).catch((error) => { - logger.error('Failed to autosave document.', { error }) - // retry in 30 seconds - this.autosave() - }) + return this.save({ manualSave: false }) + .then(() => { + this.autosaveErrorCount = 0 + }) + .catch((error) => { + logger.error('Failed to autosave document.', { error }) + // double the delay on every failed attempt + const delay = Math.pow(2, this.autosaveErrorCount) * RETRY_TIMEOUT + setTimeout(this.autosave, delay * 1000) + this.autosaveErrorCount++ + }) } } From fa00ebaa5bd54c40b5697f065fc2a6437780d46e Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Jul 2026 08:11:45 +0200 Subject: [PATCH 08/15] fix(autosave): retry if server throttled save request The server will only perform one actual save every 10 seconds. Trigger another autosave if the save attempt was throttled. `document` is udpated by throttled save attempts. Rely on its `lastSavedVersionTime` for the retry. Signed-off-by: Max --- src/services/SaveService.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 6d97063fdea..801e97e79d6 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -54,21 +54,36 @@ class SaveService { this.clear = this.autosave.clear.bind(this.autosave) } + /** + * Save the current state + * + * @param options for saving + * @param options.force force save for handling conflicts + * @param options.manualSave user initiated the saving - not autosave + * @return true on success, false if autosave was throttled by the server + */ async save({ force = false, manualSave = true } = {}) { logger.debug('[SaveService] saving', { force, manualSave }) if (!this.connection.value) { logger.warn('Could not save due to missing connection') return } + const data = this.getSaveData() try { const response = await save(this.connection.value, { - ...this.getSaveData(), + ...data, force, manualSave, }) - logger.debug('[SaveService] saved', { response }) + // update the document - even if the save was throttled this.bus.emit('save', response.data) + if (response.data.document.lastSavedVersion < data.version) { + logger.debug('[SaveService] Server throttled save request.', { response }) + return false + } + logger.debug('[SaveService] saved', { response }) this.autosave.clear() + return true } catch (e) { logger.error('Failed to save document.', { error: e }) const response = ( @@ -117,9 +132,14 @@ class SaveService { return } logger.debug('Autosaving') - return this.save({ manualSave: false }) - .then(() => { + this.save({ manualSave: false }) + .then((saved) => { this.autosaveErrorCount = 0 + // server did not save due to throttling + if (saved === false) { + // document has been updated - let autosave handle the delay. + this.autosave() + } }) .catch((error) => { logger.error('Failed to autosave document.', { error }) From 21ca3f6b8943fd700623e6830d9fbfa3bb04f2e0 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Jul 2026 08:21:34 +0200 Subject: [PATCH 09/15] fix(autosave): handle out of sync clocks * If the server claims to have saved the doc in the future use the current timestamp instead. Autosave happens at least ten seconds after `lastSavedVersionTime`. So if that was far into the future it would never happen. * If the server is living in the past delay the autosave retries without relying on `lastSavedVersionTime`. Signed-off-by: Max --- src/composables/useSaveService.ts | 10 +++++++++- src/services/SaveService.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 5e86784cf04..2779eb3f97b 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -38,7 +38,15 @@ export const provideSaveService = ( * @param event.document latest state of the document */ function updateDocument(event: { document: Document }) { - document.value = event.document + // Limit lastSavedVersionTime to now. No saving from the future. + const lastSavedVersionTime = Math.min( + event.document.lastSavedVersionTime, + Math.ceil(Date.now() / 1000), + ) + document.value = { + ...event.document, + lastSavedVersionTime, + } } syncService.bus.on('opened', updateDocument) syncService.bus.on('change', updateDocument) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 801e97e79d6..c7c13b65728 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -137,8 +137,8 @@ class SaveService { this.autosaveErrorCount = 0 // server did not save due to throttling if (saved === false) { - // document has been updated - let autosave handle the delay. - this.autosave() + // Make sure to not hammer the server if clocks are out of sync. + setTimeout(this.autosave, SERVER_AUTOSAVE_INTERVAL * 1000) } }) .catch((error) => { From c4e62171e9d202fbce7160cee51602cd4630fa9b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 28 Jul 2026 10:10:30 +0200 Subject: [PATCH 10/15] chore(test): always retry autosave after 10 seconds Adding a random offset makes determenistic testing harder. Now we also do not depend on in sync clocks. Signed-off-by: Max --- playwright/e2e/autosave.spec.ts | 52 +++++++++++++++------- playwright/support/fixtures/upload-file.ts | 4 +- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/playwright/e2e/autosave.spec.ts b/playwright/e2e/autosave.spec.ts index 7436e48b1e0..c9e39433333 100644 --- a/playwright/e2e/autosave.spec.ts +++ b/playwright/e2e/autosave.spec.ts @@ -14,39 +14,55 @@ const test = mergeTests(editorTest, offlineTest, uploadFileTest) // we cannot run tests in parallel. test.describe.configure({ mode: 'serial' }) +// Files were created 10 seconds ago so there's no throttling to begin with. +test.use({ mtime: Date.now() / 1000 - 10 }) + test.beforeEach(async ({ open }) => { await open() }) -test('saves after 30 seconds', async ({ editor, page }) => { - await page.clock.install() +test('saves after 1 second', async ({ editor }) => { await expect(editor.el).toBeVisible() await editor.typeHeading('Hello world') await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) - await page.clock.fastForward(30_000) await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) // TODO: Why does this not work? await expect(await file.getContent()).toBe('## Hello world') }) -test('saves after being disconnected for 20 sec.', async ({ +/* + * 1 second autosave debounce + * 10 seconds waiting for server to be ready again + * 1 second for the save request + */ +test('saves again within 12 seconds', async ({ editor }) => { + test.slow() + await expect(editor.el).toBeVisible() + await editor.typeHeading('Hello') + await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) + await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) + await editor.type(' again') + await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) + await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/, { timeout: 12_000 }) +}) + +test('saves after being disconnected for 5 sec.', async ({ editor, - page, setOffline, setOnline, }) => { - await page.clock.install() await expect(editor.el).toBeVisible() - await editor.typeHeading('Hello world') + await editor.typeHeading('Hello') + await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) + await editor.type(' again') await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) await setOffline() - await page.clock.fastForward(20_000) + await new Promise((resolve) => setTimeout(resolve, 5_000)) await setOnline() - await page.clock.fastForward(20_000) - await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) - // TODO: Why does this not work? await expect(await file.getContent()).toBe('## Hello world') + await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) + await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/, { timeout: 10_000 }) }) -test('saves after being disconnected for 2 minutes', async ({ +test('saves after being disconnected for 2 minutes.', async ({ editor, page, setOffline, @@ -54,12 +70,16 @@ test('saves after being disconnected for 2 minutes', async ({ }) => { await page.clock.install() await expect(editor.el).toBeVisible() - await editor.typeHeading('Hello world') + await editor.typeHeading('Hello') + await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) + await editor.type(' again') await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) await setOffline() - await page.clock.fastForward(120_000) + await new Promise((resolve) => setTimeout(resolve, 5_000)) + await page.clock.fastForward(110_000) await setOnline() - await page.clock.fastForward(40_000) + await new Promise((resolve) => setTimeout(resolve, 5_000)) + await page.clock.fastForward(5_000) + await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) - // TODO: Why does this not work? await expect(await file.getContent()).toBe('## Hello world') }) diff --git a/playwright/support/fixtures/upload-file.ts b/playwright/support/fixtures/upload-file.ts index 287c3934abd..0b2ad81c7d9 100644 --- a/playwright/support/fixtures/upload-file.ts +++ b/playwright/support/fixtures/upload-file.ts @@ -11,6 +11,7 @@ export interface UploadFileFixture { file: Node fileName: string fileContent: string + mtime: number oldVersions: { content?: string, mtime: number }[] open: () => Promise close: () => Promise @@ -24,6 +25,7 @@ export interface UploadFileFixture { export const test = base.extend({ fileContent: ['', { option: true }], fileName: ['empty.md', { option: true }], + mtime: [undefined, { option: true }], oldVersions: [[], { option: true }], file: async ({ fileContent, fileName, oldVersions, user }, use) => { @@ -33,7 +35,7 @@ export const test = base.extend({ for (const version of oldVersions) { await uploadVersion(version) } - const file = await uploadVersion({ content: fileContent }) + const file = await uploadVersion({ content: fileContent, mtime }) await use(file) }, From 97a669fe547f99a39f4c72eb643d8020eafc75ac Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 28 Jul 2026 10:12:17 +0200 Subject: [PATCH 11/15] fix(indexed-db): rely on autosave when recovering Autosave is triggered by the push of the steps. Saving before that delays the autosave because of server throttling. Signed-off-by: Max --- src/services/SaveService.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index c7c13b65728..6707ae55bfe 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -126,9 +126,8 @@ class SaveService { const now = Date.now() / 1000 // Server won't accept autosaves yet if (now < lastSave + SERVER_AUTOSAVE_INTERVAL) { - logger.debug('Not autosaving as last save is recent', { lastSave, now }) - const nextSave = lastSave + SERVER_AUTOSAVE_INTERVAL + Math.random() * MAX_RANDOM_AUTOSAVE_DELAY - setTimeout(this.autosave, (nextSave - now) * 1000) + logger.debug('Just saved, will try again in 10 seconds.', { lastSave, now }) + setTimeout(this.autosave, (SERVER_AUTOSAVE_INTERVAL - AUTOSAVE_DEBOUNCE) * 1000) return } logger.debug('Autosaving') @@ -138,7 +137,7 @@ class SaveService { // server did not save due to throttling if (saved === false) { // Make sure to not hammer the server if clocks are out of sync. - setTimeout(this.autosave, SERVER_AUTOSAVE_INTERVAL * 1000) + setTimeout(this.autosave, (SERVER_AUTOSAVE_INTERVAL - AUTOSAVE_DEBOUNCE) * 1000) } }) .catch((error) => { From a981f9d9ee5bce48fbd792c1365ea3fe4bbaf227 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 28 Jul 2026 10:35:25 +0200 Subject: [PATCH 12/15] chore(CI): playwright test timeout 45 seconds Some of our runners are slow. Give them more time to finish the runs. Also separate local and CI config in the config file. Signed-off-by: Max --- playwright.config.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 6d510c8d68d..8fbd1516ed9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,24 +3,40 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { ReporterDescription } from '@playwright/test' + import { defineConfig, devices } from '@playwright/test' +/** + * Used locally - i.e. if `CI` is not set as an environment variable. + */ +const LOCAL_CONFIG = { + // Just the html report with the traces + reporter: 'list', +} as const + +/** + * Used on CI - i.e. if `CI` is set as an environment variable. + */ +const CI_CONFIG = { + // ensure no `test.only` is left in the code causing false positives + forbidOnly: true, + // blob (so we can merge reports and download them for inspection), + // dot (so we have a quick overview in the logs while the tests are running) + // github (to have annotations in the PR) + reporter: [['blob'], ['line'], ['github']] as ReporterDescription[], + retries: 1, + timeout: 45_000, + // we shard to speed up the tests so no parallelism in workers + workers: 1, +} as const + /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './playwright', - // ensure no `test.only` is left in the code causing false positives - forbidOnly: !!process.env.CI, - // retry on CI only - retries: process.env.CI ? 1 : 0, - // we shard on CI to speed up the tests so no parallelism in workers - workers: process.env.CI ? 1 : undefined, - // on CI we want to have blob (so we can merge reports and download them for inspection), - // line (so we have a quick overview in the logs while the tests are running) - // github (to have annotations in the PR) - // locally we just want the html report with the traces - reporter: process.env.CI ? [['blob'], ['line'], ['github']] : 'list', + ...(process.env.CI ? CI_CONFIG : LOCAL_CONFIG), use: { // Base URL to use in actions like `await page.goto('./')`. baseURL: process.env.baseURL ?? 'http://localhost:8089/index.php/', From 0347e81ae0e65900e010e4deca211fa937c7a89c Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 28 Jul 2026 10:42:16 +0200 Subject: [PATCH 13/15] chore(test): mtime is optional in upload file fixture Signed-off-by: Max --- playwright/support/fixtures/upload-file.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/support/fixtures/upload-file.ts b/playwright/support/fixtures/upload-file.ts index 0b2ad81c7d9..d6984032280 100644 --- a/playwright/support/fixtures/upload-file.ts +++ b/playwright/support/fixtures/upload-file.ts @@ -11,7 +11,7 @@ export interface UploadFileFixture { file: Node fileName: string fileContent: string - mtime: number + mtime?: number oldVersions: { content?: string, mtime: number }[] open: () => Promise close: () => Promise From 93b803b953d74c7f0cbf33e0520a9b41e34ec84b Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 29 Jul 2026 08:08:00 +0200 Subject: [PATCH 14/15] fix(autosave): throttle to SERVER_AUTOSAVE_INTERVAL Only consider the local clock. Avoid problems if clocks are out of sync or if the server is unresponsive and `lastSavedVersionTime` does not get updated Signed-off-by: Max --- src/services/SaveService.ts | 38 ++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 6707ae55bfe..ad158eca6fb 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -33,7 +33,8 @@ class SaveService { bus = mitt() connection: ShallowRef document: Ref - autosaveErrorCount = 0 + lastSaveAttempt = 0 + pendingAutosave = 0 getSaveData autosave clear @@ -51,7 +52,7 @@ class SaveService { this.document = document this.getSaveData = getSaveData this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_DEBOUNCE * 1000) - this.clear = this.autosave.clear.bind(this.autosave) + this.clear = this.clearAutosave.bind(this) } /** @@ -70,6 +71,7 @@ class SaveService { } const data = this.getSaveData() try { + this.lastSaveAttempt = Date.now() const response = await save(this.connection.value, { ...data, force, @@ -82,7 +84,7 @@ class SaveService { return false } logger.debug('[SaveService] saved', { response }) - this.autosave.clear() + this.clearAutosave() return true } catch (e) { logger.error('Failed to save document.', { error: e }) @@ -122,32 +124,38 @@ class SaveService { } _autosave() { - const lastSave = this.document.value?.lastSavedVersionTime ?? 0 - const now = Date.now() / 1000 + const now = Date.now() + const nextSaveAttempt = this.lastSaveAttempt + SERVER_AUTOSAVE_INTERVAL * 1000 // Server won't accept autosaves yet - if (now < lastSave + SERVER_AUTOSAVE_INTERVAL) { - logger.debug('Just saved, will try again in 10 seconds.', { lastSave, now }) - setTimeout(this.autosave, (SERVER_AUTOSAVE_INTERVAL - AUTOSAVE_DEBOUNCE) * 1000) + if (now < nextSaveAttempt) { + if (!this.pendingAutosave) { + const wait = nextSaveAttempt - now + logger.debug(`Just saved, will try again in ${Math.ceil(wait)} seconds.`) + this.pendingAutosave = window.setTimeout(this.autosave, wait) + } return } logger.debug('Autosaving') this.save({ manualSave: false }) .then((saved) => { - this.autosaveErrorCount = 0 // server did not save due to throttling if (saved === false) { - // Make sure to not hammer the server if clocks are out of sync. - setTimeout(this.autosave, (SERVER_AUTOSAVE_INTERVAL - AUTOSAVE_DEBOUNCE) * 1000) + this.autosave() } }) .catch((error) => { logger.error('Failed to autosave document.', { error }) - // double the delay on every failed attempt - const delay = Math.pow(2, this.autosaveErrorCount) * RETRY_TIMEOUT - setTimeout(this.autosave, delay * 1000) - this.autosaveErrorCount++ + this.autosave() }) } + + clearAutosave() { + this.autosave.clear() + if (this.pendingAutosave) { + window.clearTimeout(this.pendingAutosave) + this.pendingAutosave = 0 + } + } } export { SaveService } From 4888f4fcf597f6c74f4232400d6a4060008f1258 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 29 Jul 2026 10:11:40 +0200 Subject: [PATCH 15/15] chore(tests): fix autosave test with new timing chore(tests): fix autosave test with new timing Signed-off-by: Max [skip ci] --- playwright/e2e/autosave.spec.ts | 9 ++++++--- src/services/SyncService.ts | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/playwright/e2e/autosave.spec.ts b/playwright/e2e/autosave.spec.ts index c9e39433333..0d44ebe69d8 100644 --- a/playwright/e2e/autosave.spec.ts +++ b/playwright/e2e/autosave.spec.ts @@ -71,15 +71,18 @@ test('saves after being disconnected for 2 minutes.', async ({ await page.clock.install() await expect(editor.el).toBeVisible() await editor.typeHeading('Hello') + await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) await editor.type(' again') await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) await setOffline() - await new Promise((resolve) => setTimeout(resolve, 5_000)) + // Wait long enough for the server throttling to be over. + await new Promise((resolve) => setTimeout(resolve, 10_000)) await page.clock.fastForward(110_000) await setOnline() - await new Promise((resolve) => setTimeout(resolve, 5_000)) - await page.clock.fastForward(5_000) + await expect(editor.offlineState).not.toBeVisible() await expect(editor.saveIndicator).toHaveAccessibleName(/Unsaved changes/) + // Be sure to trigger at least one autosave + await page.clock.fastForward(15_000) await expect(editor.saveIndicator).not.toHaveAccessibleName(/Unsaved changes/) }) diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index be0b4510fd8..4ac1ca72ef5 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -255,6 +255,7 @@ class SyncService { if (hadUpdate) { // this.version has been increased in receiveSteps this.bus.emit('changesPushed', { version: this.version }) + logger.debug('changesPushed', { version: this.version }) } } })