From 90bab910f5678197b806286cac6d3b4be0c9693c Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 13 Jun 2025 08:47:24 +0200 Subject: [PATCH 01/28] 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 9eb4fd9a42d..20d59a910cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "vue-click-outside": "^1.1.0", "vue-material-design-icons": "^5.3.1", "webdav": "^5.9.0", + "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.7", "yjs": "^13.6.29" @@ -21957,6 +21958,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 6b14c05f480..6fec306950a 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "vue-click-outside": "^1.1.0", "vue-material-design-icons": "^5.3.1", "webdav": "^5.9.0", + "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.7", "yjs": "^13.6.29" diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 4ad3fd03d98..91397297a72 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' @@ -390,11 +391,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() @@ -519,7 +524,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 4a69955a478dc02e33cd533d3f8cce65bdcf4c0c Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:10:53 +0200 Subject: [PATCH 02/28] 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 91397297a72..b1a36ba123a 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 { provideFileProps } from '../composables/useFileProps.ts' import { provideSaveService } from '../composables/useSaveService.ts' import { provideSyncService } from '../composables/useSyncService.ts' @@ -223,6 +223,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 } = @@ -391,10 +393,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 5aebd61355a7fe2b198bbe6cb54bccd32fe276d4 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:57:06 +0200 Subject: [PATCH 03/28] 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 102295ce12360430e17d450b5c06167103c0ebdc Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:59:45 +0200 Subject: [PATCH 04/28] 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 b1a36ba123a..16368708843 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -223,7 +223,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) @@ -233,7 +236,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 33d7825a8d02d86df7307ced89fccaaadb179cb3 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 14 Oct 2025 11:11:40 +0200 Subject: [PATCH 05/28] 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 16368708843..ed8f3b71cdd 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -223,10 +223,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 fa660a8d1a97f151a7987e64677127973be2d3dc Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 22 Oct 2025 19:28:08 +0200 Subject: [PATCH 06/28] 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 d5f7ba968007951db0fee66118d9aa20a88ea80d Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:19:53 +0100 Subject: [PATCH 07/28] 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 02570de1cdbb6f1cc201d98418c10b61a6411935 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:28:53 +0100 Subject: [PATCH 08/28] 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 e2876882c0e82831092d347adb7079f485943938 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:47:12 +0100 Subject: [PATCH 09/28] 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 1c0b2098b0081f43aa0bda7485a7174e6d05bd9e Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:59:26 +0100 Subject: [PATCH 10/28] 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 6a9ce6f4461365f9212d842ccf410d6d620666f1 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 21:14:48 +0100 Subject: [PATCH 11/28] 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 | 16 ++++++++++++++-- src/composables/useConnection.ts | 6 +++++- src/composables/useIndexedDbProvider.ts | 9 +++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index ed8f3b71cdd..a7b1cc99964 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -223,7 +223,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('/') @@ -382,6 +390,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 068b418a7fd..3aa1c063a15 100644 --- a/src/components/ViewerComponent.vue +++ b/src/components/ViewerComponent.vue @@ -5,14 +5,15 @@ @@ -35,6 +33,7 @@ import { useEditor } from '../composables/useEditor.ts' import { useEditorMethods } from '../composables/useEditorMethods.ts' import { useSaveService } from '../composables/useSaveService.ts' import { useSyncService } from '../composables/useSyncService.ts' +import { logger } from '../helpers/logger.ts' export default { name: 'CollisionResolveDialog', components: { @@ -45,13 +44,27 @@ export default { type: String, required: true, }, + readerSource: { + type: String, + required: true, + }, }, - setup() { + setup(props) { + if (!['local', 'server'].includes(props.readerSource)) { + logger.warn('Invalid reader source', props) + } const { editor } = useEditor() const { syncService } = useSyncService() const { saveService } = useSaveService() const { setContent, setEditable } = useEditorMethods(editor) + const editorSource = props.readerSource === 'local' ? 'server' : 'local' + const textForSource = { + local: t('text', 'Overwrite the file and save the unsaved changes'), + server: t('text', 'Discard the changes and edit the latest version'), + } return { + editorSource, + textForSource, setContent, setEditable, saveService, @@ -65,16 +78,22 @@ export default { } }, methods: { - resolveThisVersion() { + useEditorVersion() { this.clicked = true - this.saveService.forceSave().then(() => this.syncService.syncUp()) + this.saveService.forceSave().then(() => { + this.syncService.syncUp() + this.$emit('resolved') + }) this.setEditable(!this.readOnly) }, - resolveServerVersion() { + useReaderVersion() { this.clicked = true this.setEditable(!this.readOnly) this.setContent(this.otherVersion) - this.saveService.forceSave().then(() => this.syncService.syncUp()) + this.saveService.forceSave().then(() => { + this.syncService.syncUp() + this.$emit('resolved') + }) }, }, } diff --git a/src/components/Editor.vue b/src/components/Editor.vue index d3b35aed13d..bdb68041ecb 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -12,7 +12,11 @@ :class="{ 'is-mobile': isMobile }" tabindex="-1"> - + Date: Wed, 14 Jan 2026 13:03:25 +0100 Subject: [PATCH 25/28] chore(split): reload handling local change in Editor.js Signed-off-by: Max --- src/components/Editor.js | 43 ++++++++++++++++++++++++++++ src/components/PublicFilesEditor.vue | 2 +- src/components/ViewerComponent.vue | 27 +++-------------- src/editor.js | 2 +- src/views/DirectEditing.vue | 2 +- src/views/RichWorkspace.vue | 2 +- 6 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 src/components/Editor.js diff --git a/src/components/Editor.js b/src/components/Editor.js new file mode 100644 index 00000000000..f0aa599f8eb --- /dev/null +++ b/src/components/Editor.js @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineComponent, h, nextTick, ref, watch } from 'vue' +import Editor from './Editor.vue' + +export default defineComponent({ + emits: ['focus', 'ready'], + props: Editor.props, + setup(props, { attrs, emit }) { + const reloading = ref(false) + const localChange = ref('') + watch(reloading, (val) => { + if (val) { + nextTick(() => { + reloading.value = false + }) + } + }) + return () => + !reloading.value + && h(Editor, { + attrs, + props: { + ...props, + localChange: localChange.value, + }, + on: { + focus: () => emit('focus'), + ready: () => emit('ready'), + reload: (change) => { + localChange.value = change + reloading.value = true + }, + resolved: () => { + localChange.value = '' + }, + }, + }) + }, +}) diff --git a/src/components/PublicFilesEditor.vue b/src/components/PublicFilesEditor.vue index ae55acd7482..4439c45da43 100644 --- a/src/components/PublicFilesEditor.vue +++ b/src/components/PublicFilesEditor.vue @@ -21,7 +21,7 @@ export default { name: 'PublicFilesEditor', components: { NcModal, - Editor: () => import('./Editor.vue'), + Editor: () => import('./Editor.js'), }, props: { fileId: { diff --git a/src/components/ViewerComponent.vue b/src/components/ViewerComponent.vue index 0168578a131..6bc64a95c4c 100644 --- a/src/components/ViewerComponent.vue +++ b/src/components/ViewerComponent.vue @@ -5,19 +5,16 @@