From a76291e490883c2ac35d2f1395ba46ba7f0c7309 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 24 Feb 2026 23:23:42 -0300 Subject: [PATCH 1/6] perf(comments): virtualize floating comment bubbles with IntersectionObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-pass render pipeline (hidden measurement container + visible container mounting all 588 CommentDialogs simultaneously) with IntersectionObserver virtualization. Only comments within 600px of the viewport mount the heavy CommentDialog component; the rest are lightweight placeholder divs. - allPositions computed with collision avoidance replaces imperative processLocations - Module-level _heightsCache survives component remounts - verticalOffset converted to computed for proper reactivity - SuperDoc.vue watcher changed to one-way false→true (prevents unmount/remount flicker) - Expose getCommentPositionKey from comments-store for canonical ID resolution SD-1997 --- packages/superdoc/src/SuperDoc.vue | 8 +- .../CommentsLayer/FloatingComments.vue | 351 ++++++++++-------- .../superdoc/src/stores/comments-store.js | 3 + 3 files changed, 211 insertions(+), 151 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 48951fc900..6b1b7cf6c7 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -956,11 +956,13 @@ watch( }, ); +// Ensure hasInitializedLocations is set when comments arrive (backup for cases +// where handleDocumentReady hasn't fired yet). Never toggle false→true→false — +// the virtualized FloatingComments reacts to comment changes via computed properties. watch(getFloatingComments, () => { - hasInitializedLocations.value = false; - nextTick(() => { + if (!hasInitializedLocations.value) { hasInitializedLocations.value = true; - }); + } }); const { diff --git a/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue b/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue index 481c3626d1..985d8ae923 100644 --- a/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue +++ b/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue @@ -1,10 +1,18 @@ + + diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 11b31c4e42..12bf42db82 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -781,6 +781,8 @@ export const useCommentsStore = defineStore('comments', () => { ...(formatMark && { formatMark: formatMark.mark }), }; + // nodes/deletionNodes are unused here — the function resolves them from + // trackedChangesForId which already contains all document positions for this ID. const params = createOrUpdateTrackedChangeComment({ event: 'add', marks, @@ -1019,6 +1021,7 @@ export const useCommentsStore = defineStore('comments', () => { getGroupedComments, getCommentsByPosition, getFloatingComments, + getCommentPositionKey, getCommentPosition, getCommentAnchoredText, getCommentAnchorData, From 74a1652fa4a5e5b203424f288ecb650800188c5f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 25 Feb 2026 07:33:12 -0300 Subject: [PATCH 2/6] test(comments): add behavior tests for floating comment virtualization Verify that: - Floating comment placeholders appear after creating tracked changes - CommentDialogs mount near the viewport via IntersectionObserver - Typing does not cause comment sidebar to flicker (hasInitializedLocations fix) SD-1997 --- .../floating-comments-virtualization.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/behavior/tests/comments/floating-comments-virtualization.spec.ts diff --git a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts new file mode 100644 index 0000000000..30b15798f2 --- /dev/null +++ b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listTrackChanges, listComments } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); + +test('@behavior SD-1997: floating comment bubbles render after tracked changes', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + // Switch to suggesting mode so edits create tracked changes + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + // Create several tracked changes + for (let i = 0; i < 5; i++) { + await superdoc.type(`tracked change ${i + 1}`); + await superdoc.newLine(); + await superdoc.waitForStable(); + } + + // Verify tracked changes were created + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .toBeGreaterThanOrEqual(5); + + // Verify floating comment placeholders appear in the sidebar + const placeholders = superdoc.page.locator('.comment-placeholder'); + await expect(placeholders.first()).toBeAttached({ timeout: 10_000 }); + + const count = await placeholders.count(); + expect(count).toBeGreaterThanOrEqual(5); + + // Verify at least one CommentDialog is mounted (visible near viewport) + const dialogs = superdoc.page.locator('.comment-placeholder .comments-dialog'); + await expect(dialogs.first()).toBeAttached({ timeout: 10_000 }); +}); + +test('@behavior SD-1997: typing does not flicker floating comments', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + // Create a tracked change in suggesting mode + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await superdoc.type('initial tracked change'); + await superdoc.waitForStable(); + + // Wait for the floating comment to appear + const placeholders = superdoc.page.locator('.comment-placeholder'); + await expect(placeholders.first()).toBeAttached({ timeout: 10_000 }); + + const initialCount = await placeholders.count(); + expect(initialCount).toBeGreaterThanOrEqual(1); + + // Now type more text — placeholders should remain in the DOM (no flicker) + await superdoc.newLine(); + await superdoc.type('more text here'); + + // Placeholders should still be attached without disappearing + await expect(placeholders.first()).toBeAttached(); + const afterTypingCount = await placeholders.count(); + expect(afterTypingCount).toBeGreaterThanOrEqual(initialCount); + + // Verify the comment dialog content is still visible (not unmounted/remounted empty) + const dialog = superdoc.page.locator('.comment-placeholder .comments-dialog'); + await expect(dialog.first()).toBeAttached({ timeout: 5_000 }); +}); From 105dd3072ac48909ec618fa35f44a5d8a9934fd7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 25 Feb 2026 07:45:59 -0300 Subject: [PATCH 3/6] chore: fix lockfile missing SDK optional dependency specifiers The SDK platform packages (@superdoc-dev/sdk-darwin-arm64, etc.) were added as optionalDependencies in a093891e9 but pnpm skipped resolving them because of os/cpu restrictions, leaving the lockfile without specifiers. CI with --frozen-lockfile then failed. --- pnpm-lock.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d09cff61b9..6f18c06055 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -989,6 +989,22 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + optionalDependencies: + '@superdoc-dev/sdk-darwin-arm64': + specifier: 1.0.0-alpha.6 + version: link:platforms/sdk-darwin-arm64 + '@superdoc-dev/sdk-darwin-x64': + specifier: 1.0.0-alpha.6 + version: link:platforms/sdk-darwin-x64 + '@superdoc-dev/sdk-linux-arm64': + specifier: 1.0.0-alpha.6 + version: link:platforms/sdk-linux-arm64 + '@superdoc-dev/sdk-linux-x64': + specifier: 1.0.0-alpha.6 + version: link:platforms/sdk-linux-x64 + '@superdoc-dev/sdk-windows-x64': + specifier: 1.0.0-alpha.6 + version: link:platforms/sdk-windows-x64 packages/sdk/langs/node/platforms/sdk-darwin-arm64: {} From 880f5d9b09639fa5e81a62d3a523b30c1f80e525 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 25 Feb 2026 07:51:18 -0300 Subject: [PATCH 4/6] chore: ignore SDK scripts in eslint config The .mjs files in packages/sdk/scripts/ cause parse errors with typescript-eslint. These are standalone Node scripts, not part of the library source. --- eslint.config.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 35c5cdbca3..3a9f679020 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,6 +46,8 @@ export default [ '**/commitlint.config.js', // E2E tests 'e2e-tests/**', + // SDK scripts — ESM parsed incorrectly by typescript-eslint + 'packages/sdk/scripts/**', ], }, { From 8c2d48adc05156febc74ceba1eda451c998689d6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 25 Feb 2026 08:16:44 -0300 Subject: [PATCH 5/6] fix(comments): resolve activeComment key mismatch for imported Word comments activeComment stores commentId but pos.id uses getCommentPositionKey (prefers importedId). For imported comments where importedId !== commentId, the template guard failed and the active dialog could unmount when scrolled out of viewport. --- .../src/components/CommentsLayer/FloatingComments.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue b/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue index 985d8ae923..a55a10cd5c 100644 --- a/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue +++ b/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue @@ -33,6 +33,15 @@ const { activeZoom } = storeToRefs(superdocStore); const floatingCommentsContainer = ref(null); const commentsRenderKey = ref(0); +// Resolve activeComment (which stores commentId) to the position key used by allPositions +// (which prefers importedId). Without this, imported Word comments where importedId !== commentId +// would fail the template guard and could unmount when scrolled out of the observer viewport. +const activeCommentKey = computed(() => { + if (!activeComment.value) return null; + const comment = commentsStore.getComment(activeComment.value); + return comment ? commentsStore.getCommentPositionKey(comment) : null; +}); + // Heights: measured (actual) or estimated. Seeded from module-level cache to // survive remounts triggered by hasInitializedLocations toggle in SuperDoc.vue. const measuredHeights = ref({ ..._heightsCache }); @@ -250,7 +259,7 @@ onBeforeUnmount(() => { > Date: Wed, 25 Feb 2026 08:37:46 -0300 Subject: [PATCH 6/6] fix(tests): update comment test selectors for virtualized FloatingComments The selector .floating-comment > .comments-dialog expected a parent-child relationship, but Vue merges both classes onto the same DOM element. Changed to .comment-placeholder .comments-dialog which matches the virtualized template. --- .../behavior/tests/comments/basic-comment-insertion.spec.ts | 4 ++-- tests/behavior/tests/comments/edit-comment-text.spec.ts | 6 ++++-- .../comments/tracked-change-replacement-bubble.spec.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/behavior/tests/comments/basic-comment-insertion.spec.ts b/tests/behavior/tests/comments/basic-comment-insertion.spec.ts index ff3688eb2e..43738a89de 100644 --- a/tests/behavior/tests/comments/basic-comment-insertion.spec.ts +++ b/tests/behavior/tests/comments/basic-comment-insertion.spec.ts @@ -95,9 +95,9 @@ test('add a comment via the UI bubble', async ({ superdoc }) => { } // Verify the comment text appears in the floating dialog - const commentDialog = superdoc.page.locator('.floating-comment > .comments-dialog').last(); + const commentDialog = superdoc.page.locator('.comment-placeholder .comments-dialog').last(); const commentText = commentDialog.locator('.comment-body .comment'); - await expect(commentText.first()).toBeAttached({ timeout: 5_000 }); + await expect(commentText.first()).toBeAttached({ timeout: 10_000 }); await expect(commentText.first()).toContainText('UI comment on selected text'); await superdoc.snapshot('comment added via UI'); diff --git a/tests/behavior/tests/comments/edit-comment-text.spec.ts b/tests/behavior/tests/comments/edit-comment-text.spec.ts index 028d18de73..e83ae6da43 100644 --- a/tests/behavior/tests/comments/edit-comment-text.spec.ts +++ b/tests/behavior/tests/comments/edit-comment-text.spec.ts @@ -59,8 +59,10 @@ test('editing a comment updates its text', async ({ superdoc }) => { await superdoc.waitForStable(); // After update the dialog loses is-active; verify the text changed via the visible sidebar dialog - const updatedDialog = superdoc.page.locator('.floating-comment > .comments-dialog'); - await expect(updatedDialog.locator('.comment-body .comment').first()).toContainText('changed comment'); + const updatedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog'); + await expect(updatedDialog.locator('.comment-body .comment').first()).toContainText('changed comment', { + timeout: 10_000, + }); // CommentInfo.text is optional in the contract — some adapters don't populate it. // Verify via the API when available; the DOM assertion above covers all adapters. const listed = await listComments(superdoc.page, { includeResolved: true }); diff --git a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts index a381624fa8..a31576c1d2 100644 --- a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts @@ -25,10 +25,10 @@ test('SD-1739 tracked change replacement does not duplicate text in bubble', asy // The floating dialog should show the tracked change with correct text // (Bug SD-1739 would show "Added: redliningg" with duplicated trailing char) - const dialog = superdoc.page.locator('.floating-comment > .comments-dialog', { + const dialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { has: superdoc.page.locator('.tracked-change-text'), }); - await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); // "Added:" label with "redlining" text — must NOT contain "redliningg" const addedText = dialog.locator('.tracked-change-text').first();