From 8a5b1e66c80740b7b3f11faad024a1522d505148 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 13 Jun 2025 08:47:24 +0200 Subject: [PATCH 01/54] 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 aa7db21d982..3305b15039a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,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.30" @@ -21885,6 +21886,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 6bc5dfa762b..7e2e907215f 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,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.30" diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 305a5b0bb46..58b83d84679 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -87,7 +87,8 @@ import { File } from '@nextcloud/files' import { Collaboration } from '@tiptap/extension-collaboration' import { useElementSize } from '@vueuse/core' import { defineComponent, inject, 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' @@ -397,11 +398,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() @@ -526,7 +531,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 10357e50659cfdd155c4908d300d874fd7099d9a Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:10:53 +0200 Subject: [PATCH 02/54] chore(split) useIndexedDbProvider from Editor.vue Signed-off-by: Max --- src/components/Editor.vue | 8 ++++---- src/composables/useIndexedDbProvider.ts | 26 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 src/composables/useIndexedDbProvider.ts diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 58b83d84679..8ba685a562f 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -88,6 +88,7 @@ import { Collaboration } from '@tiptap/extension-collaboration' import { useElementSize } from '@vueuse/core' import { defineComponent, inject, ref, shallowRef, watch } from 'vue' import { IndexeddbPersistence } from 'y-indexeddb' +import { defineComponent, ref, shallowRef, watch } from 'vue' import { Doc, logUpdate } from 'yjs' import Autofocus from '../extensions/Autofocus.js' @@ -108,6 +109,7 @@ import { provideEditorHeadings } from '../composables/useEditorHeadings.ts' import { useEditorMethods } from '../composables/useEditorMethods.ts' import { provideEditorWidth } from '../composables/useEditorWidth.ts' import { provideFileProps } from '../composables/useFileProps.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' @@ -228,6 +230,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 } = @@ -398,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 0d9bf36c5ae36dfb516d090eef114d3a8be875a6 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:57:06 +0200 Subject: [PATCH 03/54] 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 | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index 111012362fe..800b659ced0 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; @@ -37,10 +36,6 @@ public function __construct( protected function run($argument): void { $this->logger->debug('Run cleanup job for text documents'); foreach ($this->documentService->getAllWithNoActiveSession() as $document) { - try { - $this->documentService->resetDocument($document->getId()); - } catch (DocumentHasUnsavedChangesException) { - } $this->attachmentService->cleanupAttachments($document->getId()); } From ee2e7d2620479d2a248c33eccb7de72078e2ec29 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:59:45 +0200 Subject: [PATCH 04/54] 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 8ba685a562f..ac10c3b4556 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -230,7 +230,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) @@ -240,7 +243,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 c5f7643ff9ab13f6ab16a5dae156d8957d540986 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 14 Oct 2025 11:11:40 +0200 Subject: [PATCH 05/54] 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 ac10c3b4556..ebc1b6cf960 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -230,10 +230,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) @@ -294,6 +292,7 @@ export default defineComponent({ return { awareness, connection, + dirty, editor, el, hasConnectionIssue, @@ -322,7 +321,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 a384b580033a7951897b1f0047a093948e2eda6e Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 22 Oct 2025 19:28:08 +0200 Subject: [PATCH 06/54] 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 855e1606f39f781869901d01ae6a98bf7e75b601 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:19:53 +0100 Subject: [PATCH 07/54] 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 f2f567b126388497d3bf89b53934c19beaa19a25 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:28:53 +0100 Subject: [PATCH 08/54] 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 9bbf8b55923a134062b785f672462d5b45dcb73f Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:47:12 +0100 Subject: [PATCH 09/54] 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 9d3a57ed3a7f524b8c5fb0fbb57609eab84460cd Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:59:26 +0100 Subject: [PATCH 10/54] 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 d3c26ab7524..2408ab559f9 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 3da5b4d7d92372e97ab27c385af1a4ffbd6eef21 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 21:14:48 +0100 Subject: [PATCH 11/54] 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 ebc1b6cf960..edf8cfd676b 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -230,7 +230,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) @@ -291,6 +291,7 @@ export default defineComponent({ return { awareness, + clearIndexedDb, connection, dirty, editor, @@ -346,6 +347,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 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 a7dd8c28678..5c1c2bfc0f9 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/54] 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 @@