From fa5443f599e3a97c11903bbcd7eda7b79d6c7820 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 13 Jun 2025 08:47:24 +0200 Subject: [PATCH 01/20] wip: try y-indexeddb For now text is sometimes duplicated Signed-off-by: Max --- package-lock.json | 21 +++++++++++++++++++++ package.json | 1 + src/components/Editor.vue | 17 +++++++++++------ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e79d49f6e0..d038b5da03c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "vue-click-outside": "^1.1.0", "vue-material-design-icons": "^5.3.1", "webdav": "^5.8.0", + "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.7", "yjs": "^13.6.28" @@ -21802,6 +21803,26 @@ "node": ">=0.4" } }, + "node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y-prosemirror": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", diff --git a/package.json b/package.json index ad5d674f867..3d36e5a183b 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "vue-click-outside": "^1.1.0", "vue-material-design-icons": "^5.3.1", "webdav": "^5.8.0", + "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.7", "yjs": "^13.6.28" diff --git a/src/components/Editor.vue b/src/components/Editor.vue index dd720865b40..abb6392475e 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -86,7 +86,8 @@ import { File } from '@nextcloud/files' import { Collaboration } from '@tiptap/extension-collaboration' import { useElementSize } from '@vueuse/core' import { defineComponent, ref, shallowRef, watch } from 'vue' -import { Doc } from 'yjs' +import { IndexeddbPersistence } from 'y-indexeddb' +import { Doc, logUpdate } from 'yjs' import Autofocus from '../extensions/Autofocus.js' import { provideEditor } from '../composables/useEditor.ts' @@ -399,11 +400,15 @@ export default defineComponent({ exposeForDebugging(this) }, created() { + this.$indexedDbProvider = new IndexeddbPersistence(this.fileId, this.ydoc) + this.$indexedDbProvider.on('synced', (provider) => { + console.info('synced from indexeddb', provider) + }) // The following can be useful for debugging ydoc updates - // this.ydoc.on('update', function(update, origin, doc, tr) { - // console.debug('ydoc update', update, origin, doc, tr) - // Y.logUpdate(update) - // }); + this.ydoc.on('update', function (update, origin, doc, tr) { + console.debug('ydoc update', update, origin, doc, tr) + logUpdate(update) + }) this.$attachmentResolver = null if (this.active && this.hasDocumentParameters) { this.initSession() @@ -527,7 +532,7 @@ export default defineComponent({ this.document = document this.syncError = null - this.setEditable(this.editMode && !this.requireReconnect) + this.setEditable(this.editMode) // && !this.requireReconnect) }, onCreate({ editor }) { From dad1fc4ffce1b04b26a0ec68fa722d809edddc2e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:10:53 +0200 Subject: [PATCH 02/20] chore(split) useIndexedDbProvider from Editor.vue Signed-off-by: Max --- src/components/Editor.vue | 8 +++----- src/composables/useIndexedDbProvider.ts | 26 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/composables/useIndexedDbProvider.ts diff --git a/src/components/Editor.vue b/src/components/Editor.vue index abb6392475e..2f2d3471345 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -86,7 +86,6 @@ import { File } from '@nextcloud/files' import { Collaboration } from '@tiptap/extension-collaboration' import { useElementSize } from '@vueuse/core' import { defineComponent, ref, shallowRef, watch } from 'vue' -import { IndexeddbPersistence } from 'y-indexeddb' import { Doc, logUpdate } from 'yjs' import Autofocus from '../extensions/Autofocus.js' @@ -102,6 +101,7 @@ import { useDelayedFlag } from '../composables/useDelayedFlag.ts' import { provideEditorHeadings } from '../composables/useEditorHeadings.ts' import { useEditorMethods } from '../composables/useEditorMethods.ts' import { provideEditorWidth } from '../composables/useEditorWidth.ts' +import { useIndexedDbProvider } from '../composables/useIndexedDbProvider.ts' import { provideSaveService } from '../composables/useSaveService.ts' import { provideSyncService } from '../composables/useSyncService.ts' import { useSyntaxHighlighting } from '../composables/useSyntaxHighlighting.ts' @@ -225,6 +225,8 @@ export default defineComponent({ }) const ydoc = new Doc() const awareness = new Awareness(ydoc) + useIndexedDbProvider(props, ydoc) + const hasConnectionIssue = ref(false) const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue) const { isPublic, isRichEditor, isRichWorkspace, useTableOfContents } = @@ -400,10 +402,6 @@ export default defineComponent({ exposeForDebugging(this) }, created() { - this.$indexedDbProvider = new IndexeddbPersistence(this.fileId, this.ydoc) - this.$indexedDbProvider.on('synced', (provider) => { - console.info('synced from indexeddb', provider) - }) // The following can be useful for debugging ydoc updates this.ydoc.on('update', function (update, origin, doc, tr) { console.debug('ydoc update', update, origin, doc, tr) diff --git a/src/composables/useIndexedDbProvider.ts b/src/composables/useIndexedDbProvider.ts new file mode 100644 index 00000000000..12772086a34 --- /dev/null +++ b/src/composables/useIndexedDbProvider.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { IndexeddbPersistence } from 'y-indexeddb' +import type { Doc } from 'yjs' + +/** + * Initialize a indexed db provider for the given ydoc + * @param props Props of the editor component. + * @param props.fileId Fileid of the file. + * @param ydoc Document to sync via the provider + */ +export function useIndexedDbProvider( + props: { + fileId: number + }, + ydoc: Doc, +) { + const name = `${props.fileId}` + const indexedDbProvider = new IndexeddbPersistence(name, ydoc) + indexedDbProvider.on('synced', (provider: IndexeddbPersistence) => { + console.info('synced from indexeddb', provider) + }) +} From 740e864d8691ca845a5349b27734f08f00be4e65 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:57:06 +0200 Subject: [PATCH 03/20] fix(cron): do not reset document Keep the baseVersionEtag and the editing session around in case people who are offline connect again later. Signed-off-by: Max --- lib/Cron/Cleanup.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index eb749c6da30..839a20ff11c 100644 --- a/lib/Cron/Cleanup.php +++ b/lib/Cron/Cleanup.php @@ -11,7 +11,6 @@ namespace OCA\Text\Cron; -use OCA\Text\Exception\DocumentHasUnsavedChangesException; use OCA\Text\Service\AttachmentService; use OCA\Text\Service\DocumentService; use OCA\Text\Service\SessionService; @@ -42,11 +41,6 @@ protected function run($argument): void { // Inactive sessions will get removed further down and will trigger a reset next time continue; } - - try { - $this->documentService->resetDocument($document->getId()); - } catch (DocumentHasUnsavedChangesException) { - } $this->attachmentService->cleanupAttachments($document->getId()); } From 761da7ce42cf0efc72adba9da0d6e421a153fe7a Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:59:45 +0200 Subject: [PATCH 04/20] enh(yjs): store baseVersionEtag alongside doc ... and use it to check if the server is still on the same session. Signed-off-by: Max --- cypress/e2e/api/SyncServiceProvider.spec.js | 21 +++++---- src/components/Editor.vue | 11 ++++- src/composables/useConnection.ts | 47 +++++++++++++++------ src/composables/useIndexedDbProvider.ts | 20 +++++++++ src/tests/services/SyncService.spec.ts | 15 +++++-- 5 files changed, 86 insertions(+), 28 deletions(-) diff --git a/cypress/e2e/api/SyncServiceProvider.spec.js b/cypress/e2e/api/SyncServiceProvider.spec.js index 8844754aab0..257315af0e4 100644 --- a/cypress/e2e/api/SyncServiceProvider.spec.js +++ b/cypress/e2e/api/SyncServiceProvider.spec.js @@ -43,15 +43,20 @@ describe('Sync service provider', function () { */ function createProvider(ydoc) { const relativePath = '.' - const { connection, openConnection, baseVersionEtag } = provideConnection({ - fileId, - relativePath, - }) - const { syncService } = provideSyncService( - connection, - openConnection, - baseVersionEtag, + let baseVersionEtag + const setBaseVersionEtag = (val) => { + baseVersionEtag = val + } + const getBaseVersionEtag = () => baseVersionEtag + const { connection, openConnection } = provideConnection( + { + fileId, + relativePath, + }, + getBaseVersionEtag, + setBaseVersionEtag, ) + const { syncService } = provideSyncService(connection, openConnection) const queue = [] syncService.bus.on('opened', () => syncService.startSync()) return createSyncServiceProvider({ diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 2f2d3471345..3eb0b24ae7b 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -225,7 +225,10 @@ export default defineComponent({ }) const ydoc = new Doc() const awareness = new Awareness(ydoc) - useIndexedDbProvider(props, ydoc) + const { getBaseVersionEtag, setBaseVersionEtag } = useIndexedDbProvider( + props, + ydoc, + ) const hasConnectionIssue = ref(false) const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue) @@ -235,7 +238,11 @@ export default defineComponent({ isRichEditor, props, ) - const { connection, openConnection } = provideConnection(props) + const { connection, openConnection } = provideConnection( + props, + getBaseVersionEtag, + setBaseVersionEtag, + ) const { syncService } = provideSyncService(connection, openConnection) const extensions = [ Autofocus.configure({ fileId: props.fileId }), diff --git a/src/composables/useConnection.ts b/src/composables/useConnection.ts index b09558968dc..1440cbc074f 100644 --- a/src/composables/useConnection.ts +++ b/src/composables/useConnection.ts @@ -41,20 +41,26 @@ export const openDataKey = Symbol('text:opendata') as InjectionKey< * @param props.relativePath Relative path to the file. * @param props.initialSession Initial session handed to the editor in direct editing * @param props.shareToken Share token of the file. + * @param getBaseVersionEtag Async getter function for the base version etag. + * @param setBaseVersionEtag Async setter function for the base version etag. */ -export function provideConnection(props: { - fileId: number - relativePath: string - initialSession?: InitialData - shareToken?: string -}) { - let baseVersionEtag: string | undefined +export function provideConnection( + props: { + fileId: number + relativePath: string + initialSession?: InitialData + shareToken?: string + }, + getBaseVersionEtag: () => Promise, + setBaseVersionEtag: (val: string) => Promise, +) { const connection = shallowRef(undefined) const openData = shallowRef(undefined) const openConnection = async () => { + const baseVersionEtag = await getBaseVersionEtag() const guestName = localStorage.getItem('nick') ?? '' const { connection: opened, data } = - openInitialSession(props) + openInitialSession(props, baseVersionEtag) || (await open({ fileId: props.fileId, guestName, @@ -62,7 +68,7 @@ export function provideConnection(props: { filePath: props.relativePath, baseVersionEtag, })) - baseVersionEtag = data.document.baseVersionEtag + await setBaseVersionEtag(data.document.baseVersionEtag) connection.value = opened openData.value = data return data @@ -84,14 +90,27 @@ export const useConnection = () => { * @param props.relativePath Relative path to the file. * @param props.initialSession Initial session handed to the editor in direct editing * @param props.shareToken Share token of the file. + * @param baseVersionEtag Etag from the last editing session. */ -function openInitialSession(props: { - relativePath: string - initialSession?: InitialData - shareToken?: string -}) { +function openInitialSession( + props: { + relativePath: string + initialSession?: InitialData + shareToken?: string + }, + baseVersionEtag: string | undefined, +) { if (props.initialSession) { const { document, session } = props.initialSession + if (baseVersionEtag && baseVersionEtag !== document.baseVersionEtag) { + throw new Error( + 'Base version etag did not match when opening initial session.', + ) + // In order to handle this properly we'd need to: + // * fetch the file content. + // * throw the same exception as a 409 response. + // * include the file content as `outsideChange` in the error. + } const connection = { documentId: document.id, sessionId: session.id, diff --git a/src/composables/useIndexedDbProvider.ts b/src/composables/useIndexedDbProvider.ts index 12772086a34..e6bf919171e 100644 --- a/src/composables/useIndexedDbProvider.ts +++ b/src/composables/useIndexedDbProvider.ts @@ -23,4 +23,24 @@ export function useIndexedDbProvider( indexedDbProvider.on('synced', (provider: IndexeddbPersistence) => { console.info('synced from indexeddb', provider) }) + + /** + * Get the base version etag the document had when it was edited last. + */ + function getBaseVersionEtag(): Promise { + return indexedDbProvider.get('baseVersionEtag') + } + + /** + * Set the base version etag for the current connection. + * @param val the base version etag as returned by open. + */ + function setBaseVersionEtag(val: string) { + return indexedDbProvider.set('baseVersionEtag', val) + } + + return { + getBaseVersionEtag, + setBaseVersionEtag, + } } diff --git a/src/tests/services/SyncService.spec.ts b/src/tests/services/SyncService.spec.ts index 412615d993c..4e4fbfad7d6 100644 --- a/src/tests/services/SyncService.spec.ts +++ b/src/tests/services/SyncService.spec.ts @@ -43,16 +43,23 @@ const openResult = { connection, data: initialData } describe('Sync service', () => { it('opens a connection', async () => { - const { connection, openConnection, openData } = provideConnection({ - fileId: 123, - relativePath: './', - }) + const getBaseVersionEtag = vi.fn() + const setBaseVersionEtag = vi.fn() + const { connection, openConnection, openData } = provideConnection( + { + fileId: 123, + relativePath: './', + }, + getBaseVersionEtag, + setBaseVersionEtag, + ) vi.mock('../../apis/connect') vi.mocked(connect.open).mockResolvedValue(openResult) const openHandler = vi.fn() const service = new SyncService({ connection, openConnection }) service.bus.on('opened', openHandler) await service.open() + expect(setBaseVersionEtag).toHaveBeenCalledWith('etag') expect(openHandler).toHaveBeenCalledWith( expect.objectContaining({ session: initialData.session }), ) From e822638305689f1f8c7b708a7ddd227cce829668 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 14 Oct 2025 11:11:40 +0200 Subject: [PATCH 05/20] fix(offline): persist dirty state in indexed db When reopening a document that was edited offline it will also be considered dirty now. Autosave will not kick in yet... As no steps are pushed. But when closing the file it will be saved. Signed-off-by: Max --- src/components/Editor.vue | 8 +++----- src/composables/useIndexedDbProvider.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 3eb0b24ae7b..4e91cd550cc 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -225,10 +225,8 @@ export default defineComponent({ }) const ydoc = new Doc() const awareness = new Awareness(ydoc) - const { getBaseVersionEtag, setBaseVersionEtag } = useIndexedDbProvider( - props, - ydoc, - ) + const { dirty, getBaseVersionEtag, setBaseVersionEtag } = + useIndexedDbProvider(props, ydoc) const hasConnectionIssue = ref(false) const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue) @@ -285,6 +283,7 @@ export default defineComponent({ return { awareness, connection, + dirty, editor, el, hasConnectionIssue, @@ -313,7 +312,6 @@ export default defineComponent({ fileNode: null, idle: false, - dirty: false, contentLoaded: false, syncError: null, readOnly: true, diff --git a/src/composables/useIndexedDbProvider.ts b/src/composables/useIndexedDbProvider.ts index e6bf919171e..5423ed8dc5d 100644 --- a/src/composables/useIndexedDbProvider.ts +++ b/src/composables/useIndexedDbProvider.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { ref, watch } from 'vue' import { IndexeddbPersistence } from 'y-indexeddb' import type { Doc } from 'yjs' @@ -23,6 +24,14 @@ export function useIndexedDbProvider( indexedDbProvider.on('synced', (provider: IndexeddbPersistence) => { console.info('synced from indexeddb', provider) }) + const dirty = ref(false) + indexedDbProvider.get('dirty').then((val) => { + dirty.value = Boolean(val) + }) + + watch(dirty, (val) => { + indexedDbProvider.set('dirty', val ? 1 : 0) + }) /** * Get the base version etag the document had when it was edited last. @@ -40,6 +49,7 @@ export function useIndexedDbProvider( } return { + dirty, getBaseVersionEtag, setBaseVersionEtag, } From fab8003a0c86d52e47d3e2a06237a6421f4f337f Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 22 Oct 2025 19:28:08 +0200 Subject: [PATCH 06/20] chore(test): explore empty changesets Signed-off-by: Max --- src/tests/upstream/yjs.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/tests/upstream/yjs.spec.ts b/src/tests/upstream/yjs.spec.ts index 38834512559..dc87c35eb8a 100644 --- a/src/tests/upstream/yjs.spec.ts +++ b/src/tests/upstream/yjs.spec.ts @@ -42,4 +42,25 @@ describe('Yjs', function () { expect(targetMap.get('keyB')).to.be.eq('valueB') expect(targetMap.get('keyC')).to.be.eq('valueC') }) + + it('detect empty updates', function () { + const source = new Doc() + const update0 = encodeStateAsUpdate(source) + expect(update0).toMatchInlineSnapshot(` + Uint8Array [ + 0, + 0, + ] + `) + const sourceMap = source.getMap() + sourceMap.set('keyA', 'valueA') + const sourceVectorA = encodeStateVector(source) + const updateAA = encodeStateAsUpdate(source, sourceVectorA) + expect(updateAA).toMatchInlineSnapshot(` + Uint8Array [ + 0, + 0, + ] + `) + }) }) From 7b44473188e50712120718673049428b505ac750 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:19:53 +0100 Subject: [PATCH 07/20] chore(rename): use privateMethods for emitError and emitDocumentStateStep Signed-off-by: Max --- src/services/SyncService.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 8d5572a50af..9f4dd951f51 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -164,7 +164,7 @@ class SyncService { if (this.hasActiveConnection()) { return } - const data = await this.#openConnection().catch((e) => this._emitError(e)) + const data = await this.#openConnection().catch((e) => this.#emitError(e)) if (!data) { // Error was already emitted above return @@ -178,7 +178,7 @@ class SyncService { this.bus.emit('opened', data) // Emit sync after opened, so websocket onmessage comes after onopen. if (data.documentState) { - this._emitDocumentStateStep( + this.#emitDocumentStateStep( data.documentState, data.document.lastSavedVersion, ) @@ -193,18 +193,15 @@ class SyncService { this.backend?.resetRefetchTimer() } - _emitError(error: { response?: object; code?: string }) { - if (!error.response || error.code === 'ECONNABORTED') { - this.bus.emit('error', { type: ERROR_TYPE.CONNECTION_FAILED, data: {} }) - } else { - this.bus.emit('error', { - type: ERROR_TYPE.LOAD_ERROR, - data: error.response, - }) - } + #emitError(error: { response?: object; code?: string }) { + const eventData = + !error.response || error.code === 'ECONNABORTED' + ? { type: ERROR_TYPE.CONNECTION_FAILED, data: {} } + : { type: ERROR_TYPE.LOAD_ERROR, data: error.response } + this.bus.emit('error', eventData) } - _emitDocumentStateStep(documentState: string, version: number) { + #emitDocumentStateStep(documentState: string, version: number) { const documentStateStep = documentStateToStep(documentState, version) this.bus.emit('sync', { steps: [documentStateStep], @@ -257,7 +254,7 @@ class SyncService { version: number } if (documentState) { - this._emitDocumentStateStep(documentState, version) + this.#emitDocumentStateStep(documentState, version) } this.pushError = 0 this.#sending = false From f824247d2fe832d1da0d2d3ffa88965a915d30e5 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:28:53 +0100 Subject: [PATCH 08/20] chore(cleanup): _getContent alias for serialize Signed-off-by: Max --- src/services/SaveService.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 4d3f4fa8555..621f2c865b6 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -54,10 +54,6 @@ class SaveService { return this.syncService.bus.emit } - _getContent() { - return this.serialize() - } - async save({ force = false, manualSave = true } = {}) { logger.debug('[SaveService] saving', { force, manualSave }) if (!this.connection.value) { @@ -67,7 +63,7 @@ class SaveService { try { const response = await save(this.connection.value, { version: this.version, - autosaveContent: this._getContent(), + autosaveContent: this.serialize(), documentState: this.getDocumentState(), force, manualSave, @@ -88,7 +84,7 @@ class SaveService { } saveViaSendBeacon(this.connection.value, { version: this.version, - autosaveContent: this._getContent(), + autosaveContent: this.serialize(), documentState: this.getDocumentState(), }) && logger.debug('[SaveService] saved using sendBeacon') } From b7f3c01d71866c4fc4d9fcf50206802e1b831233 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:47:12 +0100 Subject: [PATCH 09/20] chore(refactor): handle open data in websocket polyfill Signed-off-by: Max --- src/helpers/yjs.ts | 16 ++++++++++++++++ src/services/SyncService.ts | 7 ------- src/services/WebSocketPolyfill.ts | 6 +++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/helpers/yjs.ts b/src/helpers/yjs.ts index 7e87ca249fb..ff74b522299 100644 --- a/src/helpers/yjs.ts +++ b/src/helpers/yjs.ts @@ -7,6 +7,7 @@ import * as decoding from 'lib0/decoding.js' import * as encoding from 'lib0/encoding.js' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' +import type { OpenData } from '../apis/connect' import type { Step } from '../services/SyncService' import { messageSync } from '../services/y-websocket.js' import { decodeArrayBuffer, encodeArrayBuffer } from './base64' @@ -37,6 +38,21 @@ export function applyDocumentState( Y.applyUpdate(ydoc, update, origin) } +/** + * Create a steps from the open response + * i.e. create a sync protocol update message from the document state + * and encode it and wrap it in a step data structure. + * + * @param data - data returned by the open request + * @return steps extracted from the open data. + */ +export function stepsFromOpenData(data: OpenData): Step[] { + if (!data.documentState) { + return [] + } + return [documentStateToStep(data.documentState, data.document.lastSavedVersion)] +} + /** * Create a step from a document state * i.e. create a sync protocol update message from it diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 9f4dd951f51..a46d3275c05 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -176,13 +176,6 @@ class SyncService { this.backend = new PollingBackend(this, this.connection.value, data) // Make sure to only emit this once the backend is in place. this.bus.emit('opened', data) - // Emit sync after opened, so websocket onmessage comes after onopen. - if (data.documentState) { - this.#emitDocumentStateStep( - data.documentState, - data.document.lastSavedVersion, - ) - } } startSync() { diff --git a/src/services/WebSocketPolyfill.ts b/src/services/WebSocketPolyfill.ts index 72b6f8ef813..1e0389ff1e3 100644 --- a/src/services/WebSocketPolyfill.ts +++ b/src/services/WebSocketPolyfill.ts @@ -3,8 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { OpenData } from '../apis/connect' import { decodeArrayBuffer, encodeArrayBuffer } from '../helpers/base64' import { logger } from '../helpers/logger.js' +import { stepsFromOpenData } from '../helpers/yjs' import getNotifyBus from './NotifyService' import type { Step, SyncService } from './SyncService' @@ -35,10 +37,11 @@ export default function initWebSocketPolyfill( this.#url = url logger.debug('WebSocketPolyfill#constructor', { url, fileId }) - this.#onOpened = () => { + this.#onOpened = (data: OpenData) => { if (syncService.hasActiveConnection()) { this.onopen?.() } + this.#processSteps(stepsFromOpenData(data)) } syncService.bus.on('opened', this.#onOpened) @@ -104,6 +107,7 @@ export default function initWebSocketPolyfill( async close() { syncService.bus.off('sync', this.#onSync) + syncService.bus.off('opened', this.#onOpened) this.#notifyPushBus?.off('notify_push', this.#onNotifyPush.bind(this)) this.onclose?.(new CloseEvent('closing')) logger.debug('Websocket closed') From eb957bf0c18d1fbc80c044b2c07229cceddf0bbf Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:59:26 +0100 Subject: [PATCH 10/20] fix(sync): only accept sync protocol and return sync step 2 Signed-off-by: Max --- cypress/e2e/api/SessionApi.spec.js | 51 ++++++++++++------------------ lib/Service/DocumentService.php | 8 +++-- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js index 13361cf8617..c84834f97d9 100644 --- a/cypress/e2e/api/SessionApi.spec.js +++ b/cypress/e2e/api/SessionApi.spec.js @@ -73,23 +73,19 @@ describe('The session Api', function () { cy.closeConnection(connection) }) - // Echoes all message types but queries - Object.entries(messages) - .filter(([key, _value]) => key !== 'query') - .forEach(([type, sample]) => { - it(`echos ${type} messages`, function () { - const steps = [sample] - const version = 0 - cy.pushSteps({ connection, steps, version }) - .its('version') - .should('eql', 0) - cy.syncSteps(connection) - .its('steps[0].data') - .should('eql', steps) - }) + // Echoes updates and responses + ;['update', 'response'].forEach((type) => { + it(`echos ${type} messages`, function () { + const steps = [messages[type]] + const version = 0 + cy.pushSteps({ connection, steps, version }) + .its('version') + .should('eql', 0) + cy.syncSteps(connection).its('steps[0].data').should('eql', steps) }) + }) - it('responds to queries', function () { + it('responds to queries with updates and responses', function () { const version = 0 Object.entries(messages).forEach(([type, sample]) => { cy.pushSteps({ connection, steps: [sample], version }) @@ -97,10 +93,13 @@ describe('The session Api', function () { cy.pushSteps({ connection, steps: [messages.query], version }).then( (response) => { cy.wrap(response).its('version').should('eql', 0) - cy.wrap(response).its('steps.length').should('eql', 1) + cy.wrap(response).its('steps.length').should('eql', 2) cy.wrap(response) .its('steps[0].data') .should('eql', [messages.update]) + cy.wrap(response) + .its('steps[1].data') + .should('eql', [messages.response]) }, ) }) @@ -111,7 +110,6 @@ describe('The session Api', function () { let connection let fileId let filePath - let joining beforeEach(function () { cy.testName().then((name) => { @@ -156,13 +154,10 @@ describe('The session Api', function () { manualSave: true, }) cy.openConnection({ fileId, filePath }) - .then(({ connection: con, data }) => { - joining = con - return data - }) - .its('documentState') + .as('joining') + .its('data.documentState') .should('eql', documentState) - cy.closeConnection(joining) + cy.get('@joining').its('connection').then(cy.closeConnection) }) afterEach(function () { @@ -175,7 +170,6 @@ describe('The session Api', function () { let connection let filePath let shareToken - let joining beforeEach(function () { cy.testName().then((name) => { @@ -232,13 +226,10 @@ describe('The session Api', function () { manualSave: true, }) cy.openConnection({ filePath: '', token: shareToken }) - .then(({ connection: con, data }) => { - joining = con - return data - }) - .its('documentState') + .as('joining') + .its('data.documentState') .should('eql', documentState) - cy.closeConnection(joining) + cy.get('@joining').its('connection').then(cy.closeConnection) }) }) diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index 7fb51e67333..756cd769ffb 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -208,8 +208,12 @@ public function addStep(Document $document, Session $session, array $steps, int if ($readOnly && $message->isUpdate()) { continue; } + // Only accept sync protocol + if ($message->getYjsMessageType() !== YjsMessage::YJS_MESSAGE_SYNC) { + continue; + } // Filter out query steps as they would just trigger clients to send their steps again - if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) { + if ($message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_STEP1) { $stepsIncludeQuery = true; } else { $stepsToInsert[] = $step; @@ -249,7 +253,7 @@ public function addStep(Document $document, Session $session, array $steps, int $stepsToReturn = []; foreach ($allSteps as $step) { $message = YjsMessage::fromBase64($step->getData()); - if ($message->getYjsMessageType() === YjsMessage::YJS_MESSAGE_SYNC && $message->getYjsSyncType() === YjsMessage::YJS_MESSAGE_SYNC_UPDATE) { + if ($message->isUpdate()) { $stepsToReturn[] = $step; } } From 01c3c9e788e75b0e5498f3fe397838d1fe69792e Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 21:14:48 +0100 Subject: [PATCH 11/20] enh(sync): recover automatically from outdated / renamed doc If no changes have been made offline clear the indexedDb cache and reload Editor.vue to load the latest editing session from the server. Signed-off-by: Max --- src/components/Editor.vue | 22 +++++++++++++++++++++- src/components/ViewerComponent.vue | 13 +++++++++++-- src/composables/useConnection.ts | 6 +++++- src/composables/useIndexedDbProvider.ts | 9 +++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 4e91cd550cc..561ab0ad825 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -225,7 +225,7 @@ export default defineComponent({ }) const ydoc = new Doc() const awareness = new Awareness(ydoc) - const { dirty, getBaseVersionEtag, setBaseVersionEtag } = + const { dirty, getBaseVersionEtag, setBaseVersionEtag, clearIndexedDb } = useIndexedDbProvider(props, ydoc) const hasConnectionIssue = ref(false) @@ -282,6 +282,7 @@ export default defineComponent({ return { awareness, + clearIndexedDb, connection, dirty, editor, @@ -337,6 +338,13 @@ export default defineComponent({ hasDocumentParameters() { return this.fileId || this.shareToken || this.initialSession }, + hasOutdatedDocument() { + return ( + this.syncError + && this.syncError.type === ERROR_TYPE.LOAD_ERROR + && this.syncError.data.status === 412 + ) + }, currentDirectory() { return this.relativePath ? this.relativePath.split('/').slice(0, -1).join('/') @@ -391,6 +399,18 @@ export default defineComponent({ } this.setEditable(!val) }, + hasOutdatedDocument(val) { + if (!val) { + return + } + if (this.dirty) { + // handle conflict between active editing session and offline content + } else { + // clear the outdated cached content and reload without it. + this.clearIndexedDb() + this.emit('reload') + } + }, }, mounted() { if (!this.richWorkspace) { diff --git a/src/components/ViewerComponent.vue b/src/components/ViewerComponent.vue index 57925e1be80..9b5993f0dc6 100644 --- a/src/components/ViewerComponent.vue +++ b/src/components/ViewerComponent.vue @@ -5,14 +5,15 @@