Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/CollaborativeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ export default defineComponent({
})
setInitialYjsState(this.ydoc, content, {
isRichEditor: this.isRichEditor,
origin: this.syncProvider,
})
}
})
Expand Down
7 changes: 5 additions & 2 deletions src/helpers/setInitialYjsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) + '<p/>'
Expand Down Expand Up @@ -54,5 +57,5 @@ export function setInitialYjsState(
}

const baseUpdate = encodeStateAsUpdate(getBaseDoc(node))
applyUpdate(ydoc, baseUpdate)
applyUpdate(ydoc, baseUpdate, origin)
}
115 changes: 115 additions & 0 deletions src/tests/helpers/setInitialYjsState.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading