From 8e5b6ddb36d09f07bcf8374e4efa9236ccdd36c5 Mon Sep 17 00:00:00 2001 From: silver Date: Tue, 25 Aug 2026 13:18:49 +0200 Subject: [PATCH] fix(setInitialYjsState): do not push or save the initial state on open Opening a document without a stored yjs state applied the generated initial state as a local update. It was then pushed to the server like a user edit, which marked the document dirty and triggered an autosave, even for a freshly created and untouched document. Apply the initial state with the sync provider as origin so it counts as received from the server. The provider sends diffs against the known server state, so the first real user edit still carries the initial state along with it. Signed-off-by: silver Assisted-by: ClaudeCode:claude-fable-5 --- src/components/CollaborativeEditor.vue | 1 + src/helpers/setInitialYjsState.ts | 7 +- src/tests/helpers/setInitialYjsState.spec.ts | 115 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/tests/helpers/setInitialYjsState.spec.ts 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) + }) + }) +})