From 736fbc905ed02ed9f38b8e8c8234265d075bdaee Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 6 Jan 2026 12:48:38 +0100 Subject: [PATCH 01/25] fix(playwright): use server master branch for unreleased version Fixed by syncing the start script with what end_to_end encryption uses. Also renders the setup steps superfluous. Signed-off-by: Max --- playwright.config.ts | 19 +++++---- playwright/start-nextcloud-server.mjs | 58 ++++++++++++++++----------- playwright/support/setup.ts | 18 --------- 3 files changed, 46 insertions(+), 49 deletions(-) delete mode 100644 playwright/support/setup.ts diff --git a/playwright.config.ts b/playwright.config.ts index f1703309101..e28e2c34e03 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -31,28 +31,31 @@ export default defineConfig({ }, projects: [ - // Our global setup to configure the Nextcloud docker container - { - name: 'setup', - testMatch: /setup\.ts$/, - }, - { name: 'chromium', use: { ...devices['Desktop Chrome'], }, - dependencies: ['setup'], }, ], webServer: { // Starts the Nextcloud docker container command: 'npm run start:nextcloud', + // we use sigterm to notify the script to stop the container + // if it does not respond, we force kill it after 10 seconds + gracefulShutdown: { + signal: 'SIGTERM', + timeout: 10000, + }, reuseExistingServer: !process.env.CI, - url: 'http://127.0.0.1:8089', stderr: 'pipe', stdout: 'pipe', + url: 'http://127.0.0.1:8089', timeout: 5 * 60 * 1000, // max. 5 minutes for creating the container + wait: { + // we wait for this line to appear in the output of the webserver until consider it done + stdout: /Nextcloud is now ready to use/, + }, }, }) diff --git a/playwright/start-nextcloud-server.mjs b/playwright/start-nextcloud-server.mjs index 2719c52ea3c..e36cdaedaa8 100644 --- a/playwright/start-nextcloud-server.mjs +++ b/playwright/start-nextcloud-server.mjs @@ -1,37 +1,49 @@ -/** - * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen - * SPDX-License-Identifier: AGPL-3.0-or-later +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ -import { startNextcloud, stopNextcloud } from '@nextcloud/e2e-test-server/docker' +import { + configureNextcloud, + startNextcloud, + stopNextcloud, + waitOnNextcloud, +} from '@nextcloud/e2e-test-server/docker' import { readFileSync } from 'fs' +import { execSync } from 'node:child_process' -const start = async () => { - return await startNextcloud(getBranch(), true, { +async function start() { + const appinfo = readFileSync('appinfo/info.xml').toString() + const maxVersion = appinfo.match( + //, + )?.[1] + + let branch = 'master' + if (maxVersion) { + const refs = execSync('git ls-remote --refs').toString('utf-8') + branch = refs.includes(`refs/heads/stable${maxVersion}`) + ? `stable${maxVersion}` + : branch + } + + return await startNextcloud(branch, true, { exposePort: 8089, }) } -const getBranch = () => { - try { - const appinfo = readFileSync('appinfo/info.xml').toString() - const maxVersion = appinfo.match( - //, - )?.[1] - return maxVersion ? `stable${maxVersion}` : undefined - } catch (err) { - if (err.code === 'ENOENT') { - console.warn('No appinfo/info.xml found. Using default server banch.') - } - } +async function stop() { + process.stderr.write('Stopping Nextcloud server…\n') + await stopNextcloud() + process.exit(0) } +process.on('SIGTERM', stop) +process.on('SIGINT', stop) + // Start the Nextcloud docker container -await start() -// Listen for process to exit (tests done) and shut down the docker container -process.on('beforeExit', (code) => { - stopNextcloud() -}) +const ip = await start() +await waitOnNextcloud(ip) +await configureNextcloud(['text', 'viewer']) // Idle to wait for shutdown while (true) { diff --git a/playwright/support/setup.ts b/playwright/support/setup.ts deleted file mode 100644 index 6d188b440a4..00000000000 --- a/playwright/support/setup.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { configureNextcloud } from '@nextcloud/e2e-test-server' -import { test as setup } from '@playwright/test' - -/** - * We use this to ensure Nextcloud is configured correctly before running our tests - * - * This can not be done in the webserver startup process, - * as that only checks for the URL to be accessible which happens already before everything is configured. - */ -setup('Configure Nextcloud', async () => { - const appsToInstall = ['text', 'viewer'] - await configureNextcloud(appsToInstall) -}) From 2a8c5cedd3b81447d2210ccf18cff5348a94ca33 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 13 Jun 2025 08:47:24 +0200 Subject: [PATCH 02/25] 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 70cf0020053..1cb99e41acd 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 52870c002e0..9163b43a2f7 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 71c8a299cf9e96ee4e0342b7f124cf46ad15d2c8 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:10:53 +0200 Subject: [PATCH 03/25] 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 7f8ee3dd08d4ab8ee340e6a5bdc224ab83cb8d5b Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:57:06 +0200 Subject: [PATCH 04/25] 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 28a8f53869a8bc5ea7cfa38e2874b070a753994f Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 4 Sep 2025 16:59:45 +0200 Subject: [PATCH 05/25] 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 a1426b80367b006b23d390c08d9610b1ebd9094e Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 14 Oct 2025 11:11:40 +0200 Subject: [PATCH 06/25] 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 84439a5f06fef0e80b43208092f435708cf59252 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 22 Oct 2025 19:28:08 +0200 Subject: [PATCH 07/25] 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 4007a94a50d85109ce00baa35eab746774e19dba Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:19:53 +0100 Subject: [PATCH 08/25] 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 8b8f2b54b324d31f9b619ac0c9b54d07361f9989 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:28:53 +0100 Subject: [PATCH 09/25] 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 c0f8e0ab005009688b77ec17972d9c9d02a006f0 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:47:12 +0100 Subject: [PATCH 10/25] 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 e2491650039f08c0690821b5c09b10b7250ca50b Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 26 Oct 2025 20:59:26 +0100 Subject: [PATCH 11/25] 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 1b8c8d88ac1b5f9dfc740091f718059358c7df2e Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 27 Oct 2025 21:14:48 +0100 Subject: [PATCH 12/25] 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 @@ @@ -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' 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 0560cfec632..ace604dd6f1 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -12,7 +12,11 @@ :class="{ 'is-mobile': isMobile }" tabindex="-1"> - +