From 78ed5a38ee180b0b115c190863abb0b665bb4131 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 9 Oct 2025 11:50:44 +0700 Subject: [PATCH 1/2] fix: data loss on initial sync * make editor editable only when remote provider synced * update state from inline commemts omly when synced Signed-off-by: Alexander Onnikov --- .../components/CollaborativeTextEditor.svelte | 17 +++++++++++++---- .../src/components/extension/inlineComment.ts | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index 1f251fa8652..68dac3ca249 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -128,9 +128,10 @@ let contentError = false let localSynced = false let remoteSynced = false + let editorReady = false - $: loading = !localSynced && !remoteSynced - $: editable = !readonly && !contentError && remoteSynced && hasAccountRole(account, AccountRole.User) + $: loading = !localSynced + $: editable = !readonly && !contentError && remoteSynced && editorReady && hasAccountRole(account, AccountRole.User) void localProvider.loaded.then(() => (localSynced = true)) void remoteProvider.loaded.then(() => (remoteSynced = true)) @@ -229,7 +230,7 @@ needFocus = false } - $: if (editor !== undefined) { + $: if (editor !== undefined && editorReady && editable !== editor.isEditable) { // When the content is invalid, we don't want to emit an update // Preventing synchronization of the invalid content const emitUpdate = !contentError @@ -429,16 +430,21 @@ ydoc, boundary, popupContainer: editorPopupContainer, - requestSideSpace + requestSideSpace, + whenSync: remoteProvider.loaded } } }, kitOptions ) + // Create editor immediately with cached content + // BUT keep it read-only until remote sync completes + // This prevents stale cached content from overwriting newer server content editor = new Editor({ extensions: [kit], element, + editable: false, editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) }, @@ -446,6 +452,9 @@ parseOptions: { preserveWhitespace: 'full' }, + onCreate: () => { + editorReady = true + }, onTransaction: () => { // force re-render so `editor.isActive` works as expected editor = editor diff --git a/plugins/text-editor-resources/src/components/extension/inlineComment.ts b/plugins/text-editor-resources/src/components/extension/inlineComment.ts index 6b055f07379..4fb525229c5 100644 --- a/plugins/text-editor-resources/src/components/extension/inlineComment.ts +++ b/plugins/text-editor-resources/src/components/extension/inlineComment.ts @@ -46,6 +46,8 @@ export interface InlineCommentExtensionOptions { minEditorWidth?: number requestSideSpace?: (width: number) => void + + whenSync?: Promise } type InlineCommentDisplayMode = 'compact' | 'full' @@ -243,11 +245,21 @@ function initCommentDecoratorView (options: InlineCommentExtensionOptions, view: view.dispatch(setMeta(view.state.tr, { threads })) } - commentMap.observe(commentMapObserver) + // Defer YMap observation until remote sync is complete to prevent race condition + // where stale cached comment positions overwrite the latest server state + const initObserver = async (): Promise => { + if (options.whenSync !== undefined) { + await options.whenSync + } + commentMap.observe(commentMapObserver) + commentMapObserver() + } + + void initObserver() + destructors.push(() => { commentMap.unobserve(commentMapObserver) }) - commentMapObserver() return { destroy () { From e5e3136e1ef412ceeaae89bb7257a0d2b0a4a4af Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 9 Oct 2025 12:47:44 +0700 Subject: [PATCH 2/2] fix: use atomic updates in inline comments Signed-off-by: Alexander Onnikov --- .../src/components/extension/inlineComment.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/plugins/text-editor-resources/src/components/extension/inlineComment.ts b/plugins/text-editor-resources/src/components/extension/inlineComment.ts index 4fb525229c5..122243addff 100644 --- a/plugins/text-editor-resources/src/components/extension/inlineComment.ts +++ b/plugins/text-editor-resources/src/components/extension/inlineComment.ts @@ -700,7 +700,10 @@ function eqSets (xs: Set, ys: Set): boolean { } function forceUpdateDecorations (view: EditorView): void { - view.dispatch(setMeta(view.state.tr, {})) + const state = getCommentDecoratorState(view.state) + if (state !== undefined) { + view.updateState(view.state) + } } function fetchCommentThreads (options: InlineCommentExtensionOptions): Map { @@ -723,15 +726,19 @@ function resolveThread (options: InlineCommentExtensionOptions, editorView: Edit if (view === undefined) return const commentMap = getYDocCommentMap(options) - - const keys = Array.from(commentMap.keys()) - for (const key of keys) { - const comment = commentMap.get(key) - if (comment?.thread === thread) commentMap.delete(key) - } - const { from, to } = view.props.range const mark = view.props.mark ?? editorView.state.schema.marks['inline-comment'] + + options.ydoc.transact(() => { + const keys = Array.from(commentMap.keys()) + for (const key of keys) { + const comment = commentMap.get(key) + if (comment?.thread === thread) { + commentMap.delete(key) + } + } + }) + editorView.dispatch(setMeta(editorView.state.tr.removeMark(from, to, mark), {})) } @@ -776,14 +783,19 @@ function updateThreadComment ( if (view !== undefined) { const { from, to } = view.props.range - let tr = setMeta(editorView.state.tr, {}) + let tr = editorView.state.tr + if (thread.messages.length === 0) { tr = tr.setSelection(TextSelection.create(editorView.state.doc, 0)) } + if (thread.messages.length === 1 && existingComment === undefined) { const mark = editorView.state.schema.mark('inline-comment', { thread: thread._id }) tr = tr.addMark(from, to, mark) } + + tr = setMeta(tr, {}) + editorView.dispatch(tr) } }