diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index da0b2f86d22..c2a01092ada 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -652,6 +652,7 @@ export default defineComponent({ }) setInitialYjsState(this.ydoc, content, { isRichEditor: this.isRichEditor, + origin: this.syncProvider, }) } }) diff --git a/src/helpers/setInitialYjsState.ts b/src/helpers/setInitialYjsState.ts index 543dbe3ddc6..b71928bd144 100644 --- a/src/helpers/setInitialYjsState.ts +++ b/src/helpers/setInitialYjsState.ts @@ -17,11 +17,14 @@ import markdownit from '../markdownit/index.js' * @param content desired content of the final document * @param options options * @param options.isRichEditor use a rich editor for the content + * @param options.origin origin of the update, e.g. the sync provider. + * Pass the sync provider to mark the initial state as received from the server + * so it does not count as local changes that need to be pushed and saved. */ export function setInitialYjsState( ydoc: Doc, content: string, - { isRichEditor }: { isRichEditor: boolean }, + { isRichEditor, origin }: { isRichEditor: boolean, origin?: unknown }, ) { const html = isRichEditor ? markdownit.render(content) + '
' @@ -54,5 +57,5 @@ export function setInitialYjsState( } const baseUpdate = encodeStateAsUpdate(getBaseDoc(node)) - applyUpdate(ydoc, baseUpdate) + applyUpdate(ydoc, baseUpdate, origin) } diff --git a/src/tests/helpers/setInitialYjsState.spec.ts b/src/tests/helpers/setInitialYjsState.spec.ts new file mode 100644 index 00000000000..8a45907784f --- /dev/null +++ b/src/tests/helpers/setInitialYjsState.spec.ts @@ -0,0 +1,115 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Mock } from 'vitest' + +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import mitt from 'mitt' +import { describe, expect, it, vi } from 'vitest' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' +import { encodeArrayBuffer } from '../../helpers/base64.ts' +import { setInitialYjsState } from '../../helpers/setInitialYjsState.ts' +import initWebSocketPolyfill from '../../services/WebSocketPolyfill.ts' +import { messageSync, WebsocketProvider } from '../../services/y-websocket.js' + +describe('setInitialYjsState', () => { + it('applies the content to the ydoc', () => { + const ydoc = new Y.Doc() + setInitialYjsState(ydoc, '# Hello world', { isRichEditor: true }) + expect(ydoc.getXmlFragment('default').length).toBeGreaterThan(0) + }) + + it('passes the given origin to the update', () => { + const ydoc = new Y.Doc() + const origin = { iAmTheOrigin: true } + const updateHandler = vi.fn() + ydoc.on('update', updateHandler) + setInitialYjsState(ydoc, '# Hello world', { isRichEditor: true, origin }) + expect(updateHandler).toHaveBeenCalledTimes(1) + expect(updateHandler.mock.calls[0][1]).toBe(origin) + }) + + describe('with the sync provider as origin', () => { + // Sync update messages sort between 'AAE' and 'AQ' in base64, + // matching the classification in Outbox.storeStep. + const isSyncUpdate = (step: Uint8Array) => { + const encoded = encodeArrayBuffer(step) + return encoded >= 'AAE' && encoded < 'AQ' + } + + const setupProvider = async (ydoc: Y.Doc) => { + const syncService = { + bus: mitt(), + open: vi.fn(async () => ({})), + hasActiveConnection: vi.fn(() => true), + sendStep: vi.fn(), + version: -1, + } + const WebSocketPolyfill = initWebSocketPolyfill( + syncService as any, + 123, + ) + const provider = new WebsocketProvider( + 'ws://localhost:1234', + 'file:123', + ydoc, + { WebSocketPolyfill: WebSocketPolyfill as any, disableBc: true }, + ) + // wait for the deferred onopen call of the polyfill + await vi.waitUntil(() => provider.wsconnected) + const sentSyncUpdates = () => (syncService.sendStep as Mock).mock.calls + .map(([step]) => step) + .filter(isSyncUpdate) + return { provider, sentSyncUpdates } + } + + it('does not send the initial state as a step', async () => { + const ydoc = new Y.Doc() + const { provider, sentSyncUpdates } = await setupProvider(ydoc) + setInitialYjsState(ydoc, '# Hello world', { + isRichEditor: true, + origin: provider, + }) + expect(sentSyncUpdates()).toHaveLength(0) + }) + + it('sends the initial state along with later local changes', async () => { + const ydoc = new Y.Doc() + const { provider, sentSyncUpdates } = await setupProvider(ydoc) + setInitialYjsState(ydoc, '# Hello world', { + isRichEditor: true, + origin: provider, + }) + // a local change without origin - as caused by user edits + const paragraph = new Y.XmlElement('paragraph') + paragraph.insert(0, [new Y.XmlText('typed later')]) + ydoc.getXmlFragment('default').insert(0, [paragraph]) + const sent = sentSyncUpdates() + expect(sent).toHaveLength(1) + // the sent update also contains the initial state + const receiving = new Y.Doc() + const decoder = decoding.createDecoder(sent[0]) + expect(decoding.readVarUint(decoder)).toBe(messageSync) + syncProtocol.readSyncMessage( + decoder, + encoding.createEncoder(), + receiving, + 'test', + ) + const received = receiving.getXmlFragment('default').toJSON() + expect(received).toContain('Hello world') + expect(received).toContain('typed later') + }) + + it('sends the initial state as a step without an origin', async () => { + const ydoc = new Y.Doc() + const { sentSyncUpdates } = await setupProvider(ydoc) + setInitialYjsState(ydoc, '# Hello world', { isRichEditor: true }) + expect(sentSyncUpdates()).toHaveLength(1) + }) + }) +})