From 60c3f194d42171d586a70d8798d024bc469c706c Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Tue, 3 Feb 2026 20:58:47 -0800 Subject: [PATCH 1/3] fix: persist collab comments initial --- .../core/collaboration/collaboration.test.js | 13 +++++ .../src/core/collaboration/helpers.js | 55 +++++++++++++------ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.js index 44114e94df..0adb0ecd9b 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.js @@ -292,6 +292,19 @@ describe('collaboration helpers', () => { expect(useCommentMock).toHaveBeenCalledTimes(2); }); + it('initCollaborationComments loads existing comments from ydoc on init', () => { + commentsArray.items = [ + new MockYMap(Object.entries({ commentId: 'c1', text: 'Hello' })), + new MockYMap(Object.entries({ commentId: 'c1', text: 'Duplicate' })), + new MockYMap(Object.entries({ commentId: 'c2', text: 'Another' })), + ]; + + initCollaborationComments(superdoc); + + expect(useCommentMock).toHaveBeenCalledTimes(2); + expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }, { normalized: 'c2' }]); + }); + it('initCollaborationComments skips when module disabled', () => { superdoc.config.modules.comments = false; initCollaborationComments(superdoc); diff --git a/packages/superdoc/src/core/collaboration/helpers.js b/packages/superdoc/src/core/collaboration/helpers.js index 6f13f3ffb9..08ea1a1c1b 100644 --- a/packages/superdoc/src/core/collaboration/helpers.js +++ b/packages/superdoc/src/core/collaboration/helpers.js @@ -13,12 +13,37 @@ export const initCollaborationComments = (superdoc) => { if (!superdoc.config.modules.comments || !superdoc.provider) return; // If we have comments and collaboration, wait for sync and then let the store know when its ready + const commentsArray = superdoc.ydoc.getArray('comments'); + const updateCommentsStore = () => { + if (!superdoc.commentsStore) return false; + const comments = commentsArray.toJSON(); + const seen = new Set(); + const filtered = []; + comments.forEach((c) => { + const key = c?.importedId ?? c?.commentId; + if (!key || seen.has(key)) return; + seen.add(key); + if (!c?.commentId) { + filtered.push({ ...c, commentId: key }); + return; + } + filtered.push(c); + }); + superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)); + return true; + }; + const onSuperDocYdocSynced = () => { + if (!updateCommentsStore()) { + setTimeout(updateCommentsStore, 0); + } // Update the editor comment locations - const parent = superdoc.commentsStore.commentsParentElement; - const ids = superdoc.commentsStore.editorCommentIds; - superdoc.commentsStore.handleEditorLocationsUpdate(parent, ids); - superdoc.commentsStore.hasSyncedCollaborationComments = true; + if (superdoc.commentsStore) { + const parent = superdoc.commentsStore.commentsParentElement; + const ids = superdoc.commentsStore.editorCommentIds; + superdoc.commentsStore.handleEditorLocationsUpdate(parent, ids); + superdoc.commentsStore.hasSyncedCollaborationComments = true; + } superdoc.provider.off('synced', onSuperDocYdocSynced); }; @@ -26,29 +51,23 @@ export const initCollaborationComments = (superdoc) => { // Listen for the synced event superdoc.provider.on('synced', onSuperDocYdocSynced); - // Get the comments map from the Y.Doc - const commentsArray = superdoc.ydoc.getArray('comments'); + // Load any existing comments immediately (in case provider synced before we subscribed) + if (!updateCommentsStore()) { + setTimeout(updateCommentsStore, 0); + } // Observe changes to the comments map commentsArray.observe((event) => { + if (!superdoc.commentsStore) return; // Ignore events if triggered by the current user const currentUser = superdoc.config.user; - const { user = {} } = event.transaction.origin; + const origin = event?.transaction?.origin; + const { user = {} } = origin || {}; if (currentUser.name === user.name && currentUser.email === user.email) return; // Update conversations - const comments = commentsArray.toJSON(); - - const seen = new Set(); - const filtered = []; - comments.forEach((c) => { - if (!seen.has(c.commentId)) { - seen.add(c.commentId); - filtered.push(c); - } - }); - superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)); + updateCommentsStore(); }); }; From 085da0b2561d0c089402a0ccbc657c9e3ce90561 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 4 Feb 2026 23:28:25 -0800 Subject: [PATCH 2/3] fix: enhance collaboration comments loading and handling --- packages/superdoc/src/SuperDoc.vue | 9 +++- packages/superdoc/src/core/SuperDoc.js | 3 ++ .../core/collaboration/collaboration.test.js | 40 ++++++++++++++ .../src/core/collaboration/helpers.js | 53 ++++++++++++------- .../superdoc/src/stores/comments-store.js | 6 +++ 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 3c377a045a..56d0402793 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -453,6 +453,11 @@ const editorOptions = (doc) => { proxy.$superdoc.listeners?.('fonts-resolved')?.length > 0 ? proxy.$superdoc.listeners('fonts-resolved')[0] : null; const useLayoutEngine = proxy.$superdoc.config.useLayoutEngine !== false; + const ydocFragment = doc.ydoc?.getXmlFragment?.('supereditor'); + const ydocMeta = doc.ydoc?.getMap?.('meta'); + const ydocHasContent = (ydocFragment && ydocFragment.length > 0) || (ydocMeta && Boolean(ydocMeta.get('docx'))); + const isNewFile = doc.isNewFile && !ydocHasContent; + const options = { isDebug: proxy.$superdoc.config.isDebug || false, documentId: doc.id, @@ -493,7 +498,7 @@ const editorOptions = (doc) => { onTransaction: onEditorTransaction, ydoc: doc.ydoc, collaborationProvider: doc.provider || null, - isNewFile: doc.isNewFile || false, + isNewFile, handleImageUpload: proxy.$superdoc.config.handleImageUpload, externalExtensions: proxy.$superdoc.config.editorExtensions || [], suppressDefaultDocxStyles: proxy.$superdoc.config.suppressDefaultDocxStyles, @@ -537,6 +542,7 @@ const onEditorCommentLocationsUpdate = (doc, { allCommentIds: activeThreadId, al commentsStore.clearEditorCommentPositions?.(); return; } + if (!allCommentPositions || Object.keys(allCommentPositions).length === 0) return; const presentation = PresentationEditor.getInstance(doc.id); if (!presentation) { @@ -549,6 +555,7 @@ const onEditorCommentLocationsUpdate = (doc, { allCommentIds: activeThreadId, al // Note: PresentationEditor's 'commentPositions' event provides fresh positions // after every layout, so this is mainly for the initial load before layout completes. const mappedPositions = presentation.getCommentBounds(allCommentPositions, layers.value); + if (!mappedPositions || Object.keys(mappedPositions).length === 0) return; handleEditorLocationsUpdate(mappedPositions, activeThreadId); }; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 7e3f94d642..6f12dbe84b 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -368,6 +368,9 @@ export class SuperDoc extends EventEmitter { this.superdocStore.init(this.config); const commentsModuleConfig = this.config.modules.comments; this.commentsStore.init(commentsModuleConfig && commentsModuleConfig !== false ? commentsModuleConfig : {}); + if (this.isCollaborative) { + initCollaborationComments(this); + } this.#syncViewingVisibility(); } diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.js index 0adb0ecd9b..c21b36e039 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite import * as collaborationModule from './collaboration.js'; import { initCollaborationComments, + loadCommentsFromYdoc, initSuperdocYdoc, makeDocumentsCollaborative, syncCommentsToClients, @@ -305,6 +306,45 @@ describe('collaboration helpers', () => { expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }, { normalized: 'c2' }]); }); + it('loadCommentsFromYdoc hydrates comments from importedId and deduplicates by stable key', () => { + commentsArray.items = [ + new MockYMap(Object.entries({ importedId: 'legacy-1', text: 'legacy without commentId' })), + new MockYMap(Object.entries({ importedId: 'legacy-1', text: 'duplicate legacy' })), + new MockYMap(Object.entries({ commentId: 'c2', text: 'normal comment' })), + new MockYMap(Object.entries({ commentId: 'c2', text: 'duplicate normal comment' })), + ]; + superdoc.provider.synced = true; + + const loaded = loadCommentsFromYdoc(superdoc); + + expect(loaded).toBe(true); + expect(useCommentMock).toHaveBeenCalledTimes(2); + expect(useCommentMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ importedId: 'legacy-1', commentId: 'legacy-1' }), + ); + expect(useCommentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ commentId: 'c2' })); + expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'legacy-1' }, { normalized: 'c2' }]); + expect(superdoc.commentsStore.hasSyncedCollaborationComments).toBe(true); + }); + + it('initCollaborationComments re-hydrates store on repeated init without duplicating listeners', () => { + commentsArray.items = [new MockYMap(Object.entries({ commentId: 'c1', text: 'first' }))]; + + initCollaborationComments(superdoc); + expect(commentsArray._observers.size).toBe(1); + expect(superdoc.provider.on).toHaveBeenCalledTimes(1); + expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }]); + + // Simulate store reset after mount; second init should re-hydrate but not add listeners again. + superdoc.commentsStore.commentsList = []; + initCollaborationComments(superdoc); + + expect(commentsArray._observers.size).toBe(1); + expect(superdoc.provider.on).toHaveBeenCalledTimes(1); + expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }]); + }); + it('initCollaborationComments skips when module disabled', () => { superdoc.config.modules.comments = false; initCollaborationComments(superdoc); diff --git a/packages/superdoc/src/core/collaboration/helpers.js b/packages/superdoc/src/core/collaboration/helpers.js index 08ea1a1c1b..5c98bf1048 100644 --- a/packages/superdoc/src/core/collaboration/helpers.js +++ b/packages/superdoc/src/core/collaboration/helpers.js @@ -3,6 +3,35 @@ import useComment from '../../components/CommentsLayer/use-comment'; import { addYComment, updateYComment, deleteYComment } from './collaboration-comments'; +/** + * Load comments from the ydoc into the comments store. + * + * @param {Object} superdoc The SuperDoc instance + * @returns {boolean} True if comments were loaded into the store + */ +export const loadCommentsFromYdoc = (superdoc) => { + if (!superdoc?.ydoc || !superdoc?.commentsStore) return false; + const commentsArray = superdoc.ydoc.getArray('comments'); + const comments = commentsArray.toJSON(); + const seen = new Set(); + const filtered = []; + comments.forEach((c) => { + const key = c?.importedId ?? c?.commentId; + if (!key || seen.has(key)) return; + seen.add(key); + if (!c?.commentId) { + filtered.push({ ...c, commentId: key }); + return; + } + filtered.push(c); + }); + superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)); + if (superdoc.provider?.synced) { + superdoc.commentsStore.hasSyncedCollaborationComments = true; + } + return true; +}; + /** * Initialize sync for comments if the module is enabled * @@ -11,27 +40,15 @@ import { addYComment, updateYComment, deleteYComment } from './collaboration-com */ export const initCollaborationComments = (superdoc) => { if (!superdoc.config.modules.comments || !superdoc.provider) return; + if (superdoc._commentsCollabInitialized) { + loadCommentsFromYdoc(superdoc); + return; + } + superdoc._commentsCollabInitialized = true; // If we have comments and collaboration, wait for sync and then let the store know when its ready const commentsArray = superdoc.ydoc.getArray('comments'); - const updateCommentsStore = () => { - if (!superdoc.commentsStore) return false; - const comments = commentsArray.toJSON(); - const seen = new Set(); - const filtered = []; - comments.forEach((c) => { - const key = c?.importedId ?? c?.commentId; - if (!key || seen.has(key)) return; - seen.add(key); - if (!c?.commentId) { - filtered.push({ ...c, commentId: key }); - return; - } - filtered.push(c); - }); - superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)); - return true; - }; + const updateCommentsStore = () => loadCommentsFromYdoc(superdoc); const onSuperDocYdocSynced = () => { if (!updateCommentsStore()) { diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 66d9192f1a..b4d362f855 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -635,6 +635,9 @@ export const useCommentsStore = defineStore('comments', () => { const deleteComment = ({ commentId: commentIdToDelete, superdoc }) => { const commentIndex = commentsList.value.findIndex((c) => c.commentId === commentIdToDelete); const comment = commentsList.value[commentIndex]; + if (!comment) { + return; + } const { commentId, importedId } = comment; const { fileId } = comment; @@ -821,6 +824,9 @@ export const useCommentsStore = defineStore('comments', () => { * @returns {void} */ const handleEditorLocationsUpdate = (allCommentPositions) => { + if ((!allCommentPositions || Object.keys(allCommentPositions).length === 0) && commentsList.value.length > 0) { + return; + } editorCommentPositions.value = allCommentPositions || {}; }; From adcadc25212c51eea07c50c560994fb9013321bd Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 4 Feb 2026 23:35:41 -0800 Subject: [PATCH 3/3] chore: formatting