Skip to content

Commit 98c540a

Browse files
fix(comments): prevent flicker when clicking comment with tracked changes (#2986)
* fix(comments): prevent flicker when clicking comment with tracked changes Add explicitlySetThreadId flag to CommentsPlugin state to prevent position-based active comment detection from overriding sidebar-initiated comment activation. When a comment is explicitly activated (e.g., sidebar click), subsequent DOM-sync selection transactions preserve the active comment as long as the cursor remains within its range. The flag clears naturally when the cursor moves outside the comment range or when the document is edited, allowing position-based detection to resume for normal user navigation. Closes #2861 * fix(comments): preserve active comment across non-collapsed selection (#2861) getActiveCommentId returns undefined for any non-collapsed selection. The plugin coerced that into commentsUpdate({activeCommentId: null}), which the host listener echoed back as a fresh setActiveComment dispatch. For tracked changes inside an inline SDT, presentation.navigateTo dispatches a collapsed cursor placement followed by a non-collapsed NodeSelection on the SDT wrapper, so the second tx blanked the just-activated comment and triggered a flicker loop (~400 toggles/sec on .track-change-focused). Skip the emit when getActiveCommentId returned no information for a non-collapsed selection. A non-collapsed selection is not a reliable signal that the user moved off the comment. * test(comments): unit tests for SD-2861 non-collapsed clear regression Four tests pinning the new contract: - preserves activeThreadId when a non-collapsed selection follows explicit activation - preserves activeThreadId when a follow-up non-collapsed selection sits entirely within the comment range (the WYSIWYG repro shape) - still clears activeThreadId when a collapsed cursor moves outside the comment range (existing behavior preserved) - does not enter a dispatch loop when a host listener re-asserts after each commentsUpdate (models the SuperDoc.vue store round-trip) Three of the four fail without the plugin fix. * test(behavior): comment activation survives non-collapsed selection (#2861) Programmatically reproduces the two-transaction pattern from presentation.navigateTo at the live editor level: setActiveComment on a comment-marked range, then a non-collapsed selection over the same range. Counts dispatches and class toggles on .track-change-focused. Without the plugin fix, dispatchCount climbs into the hundreds within a second as the host re-asserts on every commentsUpdate emit. With the fix, both counts stay bounded and the active comment survives. * fix(comments): write explicitlySetThreadId on copy, not original state Address review feedback — avoid mutating pluginState directly before spreading. Use conditional copy pattern consistent with Caio's suggestion. * refactor(comments): remove explicitlySetThreadId flag per review Drop the flag in favor of Caio's isNonCollapsedClear guard, which handles the actual flicker trigger (range selection from SDT wrapper). Keeps the dead code removal and condition simplification from the original commit. --------- Co-authored-by: Caio Pizzol <caio@superdoc.dev>
1 parent 6f01fec commit 98c540a

3 files changed

Lines changed: 271 additions & 4 deletions

File tree

packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -507,11 +507,10 @@ export const CommentsPlugin = Extension.create({
507507
}
508508

509509
// Check for changes in the actively selected comment
510-
const trChangedActiveComment = meta?.type === 'setActiveComment';
511-
if ((!tr.docChanged && tr.selectionSet) || trChangedActiveComment) {
510+
if (!tr.docChanged && tr.selectionSet) {
512511
const { selection } = tr;
512+
513513
let currentActiveThread = getActiveCommentId(newEditorState.doc, selection);
514-
if (trChangedActiveComment) currentActiveThread = meta.activeThreadId;
515514
if (
516515
meta?.type === 'setCursorById' &&
517516
meta.preferredActiveThreadId &&
@@ -521,7 +520,20 @@ export const CommentsPlugin = Extension.create({
521520
}
522521

523522
const previousSelectionId = pluginState.activeThreadId;
524-
if (previousSelectionId !== currentActiveThread) {
523+
// getActiveCommentId returns undefined for any non-collapsed selection
524+
// (its first line is `if ($from.pos !== $to.pos) return;`), and that
525+
// undefined gets coerced to null below, which would emit
526+
// commentsUpdate({activeCommentId: null}) and clear an active comment.
527+
// For tracked-change comments, presentation.navigateTo dispatches a
528+
// collapsed cursor placement immediately followed by a non-collapsed
529+
// NodeSelection on the SDT wrapper. That second tx would otherwise
530+
// overwrite the just-activated comment, the host's commentsUpdate
531+
// listener would re-assert it, and the loop would flicker forever
532+
// (issue #2861). Treat "undefined from non-collapsed" as "no
533+
// information" rather than "no active comment".
534+
const isNonCollapsedClear =
535+
currentActiveThread == null && selection && selection.$from.pos !== selection.$to.pos;
536+
if (previousSelectionId !== currentActiveThread && !isNonCollapsedClear) {
525537
// Update both the plugin state and the local variable
526538
pluginState.activeThreadId = currentActiveThread;
527539
const update = {

packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.test.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,149 @@ describe('SD-1940: no recursive dispatch from apply() on selection change', () =
16411641
});
16421642
});
16431643

1644+
// Issue #2861: clicking a comment whose range overlaps a tracked change inside an inline
1645+
// SDT triggers `presentation.navigateTo`, which dispatches a collapsed cursor placement
1646+
// followed by a non-collapsed NodeSelection on the SDT wrapper. `getActiveCommentId` early-
1647+
// returns `undefined` for any non-collapsed selection, and the plugin used to coerce that
1648+
// `undefined` into `commentsUpdate({activeCommentId: null})`, clearing the just-activated
1649+
// comment. The host's `commentsUpdate` listener then re-asserts the comment, the plugin
1650+
// re-emits, and the highlight class flickers ~400 times/second until something else changes.
1651+
describe('SD-2861: non-collapsed selection does not clear active comment', () => {
1652+
it('preserves activeThreadId when a non-collapsed selection follows explicit activation', () => {
1653+
const schema = createCommentSchema();
1654+
const commentMark = schema.marks[CommentMarkName].create({ commentId: 'thread-1', internal: true });
1655+
// "Hello" (1..6) carries the comment, " World" (6..12) does not.
1656+
const paragraph = schema.node('paragraph', null, [schema.text('Hello', [commentMark]), schema.text(' World')]);
1657+
const doc = schema.node('doc', null, [paragraph]);
1658+
const { view, editor } = createPluginStateEnvironment({ schema, doc });
1659+
1660+
// Step 1: Explicitly activate the comment (mirrors the sidebar click path).
1661+
view.dispatch(
1662+
view.state.tr.setMeta(CommentsPluginKey, {
1663+
type: 'setActiveComment',
1664+
activeThreadId: 'thread-1',
1665+
forceUpdate: true,
1666+
}),
1667+
);
1668+
expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('thread-1');
1669+
editor.emit.mockClear();
1670+
1671+
// Step 2: Non-collapsed selection that straddles the comment boundary, mimicking the
1672+
// NodeSelection that `presentation.navigateTo` produces on the SDT wrapper.
1673+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1, 8)));
1674+
1675+
// The active comment must survive. `getActiveCommentId` returned undefined (because the
1676+
// selection was non-collapsed); the plugin used to treat that as "no comment" and emit
1677+
// commentsUpdate({activeCommentId: null}). It must not.
1678+
expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('thread-1');
1679+
expect(editor.emit).not.toHaveBeenCalledWith('commentsUpdate', expect.objectContaining({ activeCommentId: null }));
1680+
});
1681+
1682+
it('preserves activeThreadId when a follow-up non-collapsed selection sits entirely within the comment range', () => {
1683+
const schema = createCommentSchema();
1684+
const commentMark = schema.marks[CommentMarkName].create({ commentId: 'thread-1', internal: true });
1685+
const trackedMark = schema.marks[TrackInsertMarkName].create({ id: 'tc-1' });
1686+
// The repro case from #2861: text carries both a comment mark and a tracked-change mark.
1687+
const paragraph = schema.node('paragraph', null, [schema.text('WYSIWYG', [commentMark, trackedMark])]);
1688+
const doc = schema.node('doc', null, [paragraph]);
1689+
const { view, editor } = createPluginStateEnvironment({ schema, doc });
1690+
1691+
view.dispatch(
1692+
view.state.tr.setMeta(CommentsPluginKey, {
1693+
type: 'setActiveComment',
1694+
activeThreadId: 'thread-1',
1695+
forceUpdate: true,
1696+
}),
1697+
);
1698+
editor.emit.mockClear();
1699+
1700+
// Non-collapsed selection that wraps the entire comment range — what navigateTo
1701+
// produces when it lands a NodeSelection on the SDT containing the tracked change.
1702+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1, 8)));
1703+
1704+
expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('thread-1');
1705+
expect(editor.emit).not.toHaveBeenCalledWith('commentsUpdate', expect.objectContaining({ activeCommentId: null }));
1706+
});
1707+
1708+
it('still clears activeThreadId when a collapsed cursor moves outside the comment range', () => {
1709+
// Regression guard: the fix only suppresses clears for non-collapsed selections.
1710+
// A real "user moved off the comment" interaction (collapsed cursor outside any comment)
1711+
// must still propagate.
1712+
const schema = createCommentSchema();
1713+
const commentMark = schema.marks[CommentMarkName].create({ commentId: 'thread-1', internal: true });
1714+
const paragraph = schema.node('paragraph', null, [schema.text('Hello', [commentMark]), schema.text(' World')]);
1715+
const doc = schema.node('doc', null, [paragraph]);
1716+
const { view, editor } = createPluginStateEnvironment({ schema, doc });
1717+
1718+
view.dispatch(
1719+
view.state.tr.setMeta(CommentsPluginKey, {
1720+
type: 'setActiveComment',
1721+
activeThreadId: 'thread-1',
1722+
forceUpdate: true,
1723+
}),
1724+
);
1725+
editor.emit.mockClear();
1726+
1727+
// Collapsed cursor at pos 9 lands inside " World", which has no comment mark.
1728+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 9)));
1729+
1730+
expect(CommentsPluginKey.getState(view.state).activeThreadId).toBeNull();
1731+
expect(editor.emit).toHaveBeenCalledWith('commentsUpdate', expect.objectContaining({ activeCommentId: null }));
1732+
});
1733+
1734+
it('does not enter a dispatch loop when a host listener re-asserts after each commentsUpdate', () => {
1735+
// Models the live system: SuperDoc.vue's onEditorCommentsUpdate calls
1736+
// commentsStore.setActiveComment(superdoc, payload.activeCommentId), which dispatches
1737+
// another setActiveComment meta back into the editor. Before the fix, the cursor + range
1738+
// tx pair from navigateTo emitted (id, null) which the listener echoed back, looping.
1739+
const schema = createCommentSchema();
1740+
const commentMark = schema.marks[CommentMarkName].create({ commentId: 'thread-1', internal: true });
1741+
const paragraph = schema.node('paragraph', null, [schema.text('Hello', [commentMark])]);
1742+
const doc = schema.node('doc', null, [paragraph]);
1743+
1744+
const { editor, view, extension } = createEditorEnvironment(schema, doc);
1745+
const plugins = extension.addPmPlugins();
1746+
view.state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1), plugins });
1747+
1748+
let dispatchCount = 0;
1749+
const originalDispatch = (tr) => {
1750+
view.state = view.state.apply(tr);
1751+
};
1752+
view.dispatch = vi.fn((tr) => {
1753+
dispatchCount += 1;
1754+
if (dispatchCount > 10) throw new Error('Dispatch loop detected — exceeded 10 dispatches');
1755+
originalDispatch(tr);
1756+
});
1757+
1758+
// Mirror the host's reaction: every commentsUpdate triggers a setActiveComment meta back
1759+
// into the editor. The store does this even when the payload's activeCommentId === null.
1760+
editor.emit = vi.fn((eventName, payload) => {
1761+
if (eventName !== 'commentsUpdate') return;
1762+
view.dispatch(
1763+
view.state.tr.setMeta(CommentsPluginKey, {
1764+
type: 'setActiveComment',
1765+
activeThreadId: payload?.activeCommentId ?? null,
1766+
forceUpdate: true,
1767+
}),
1768+
);
1769+
});
1770+
1771+
// Tx 1: cursor placement at pos 3 inside the comment.
1772+
view.dispatch(
1773+
view.state.tr
1774+
.setSelection(TextSelection.create(view.state.doc, 3))
1775+
.setMeta(CommentsPluginKey, { type: 'setCursorById', preferredActiveThreadId: 'thread-1' }),
1776+
);
1777+
// Tx 2: non-collapsed selection — the SDT-wrapper case from navigateTo.
1778+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1, 6)));
1779+
1780+
// Without the fix, dispatchCount would explode (each clear emit triggers another
1781+
// setActiveComment dispatch). The non-collapsed-clear suppression keeps it bounded.
1782+
expect(dispatchCount).toBeLessThanOrEqual(5);
1783+
expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('thread-1');
1784+
});
1785+
});
1786+
16441787
describe('Headless mode plugin behavior', () => {
16451788
it('creates a state-only plugin in headless mode (no props or view)', () => {
16461789
const editor = {

tests/behavior/tests/comments/comment-on-tracked-change.spec.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,115 @@ test('switching highlighted threads does not trigger a second delayed floating-s
147147
await expect(targetDialog.locator('.comment-body .comment').nth(0)).toContainText('abc');
148148
await expect(targetDialog.locator('.comment-body .comment').nth(1)).toContainText('xyz');
149149
});
150+
151+
// SD-2861 regression: explicit comment activation followed by a non-collapsed selection
152+
// (the shape `presentation.navigateTo` produces when landing a NodeSelection on the SDT
153+
// wrapper around a tracked change) must not enter a feedback loop. The plugin used to
154+
// coerce `getActiveCommentId`'s `undefined` return for non-collapsed selections into
155+
// `commentsUpdate({activeCommentId: null})`. The Vue host re-asserted the comment, the
156+
// plugin re-emitted, and `.track-change-focused` toggled ~400 times/second.
157+
//
158+
// This test programmatically reproduces the two-transaction pattern at the live editor
159+
// level so it covers the integrated path (plugin -> PresentationEditor bridge -> store
160+
// listener -> commands.setActiveComment) without depending on a fixture that happens to
161+
// have an SDT-wrapped tracked change.
162+
test('explicit comment activation survives a follow-up non-collapsed selection', async ({ superdoc }) => {
163+
await superdoc.loadDocument(DOC_PATH);
164+
await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 });
165+
await superdoc.waitForStable();
166+
await assertDocumentApiReady(superdoc.page);
167+
168+
const result = await superdoc.page.evaluate(async () => {
169+
const editor = (window as any).editor;
170+
const view = editor?.view;
171+
if (!view?.dispatch) throw new Error('editor.view.dispatch not available');
172+
173+
// Find the first comment-marked range in the doc and capture its id + bounds.
174+
const commentInfo = ((): { id: string; from: number; to: number } | null => {
175+
let id: string | null = null;
176+
let from = -1;
177+
let to = -1;
178+
view.state.doc.descendants((node: any, pos: number) => {
179+
for (const mark of node.marks ?? []) {
180+
if (mark.type.name !== 'commentMark') continue;
181+
const candidateId = mark.attrs?.commentId ?? mark.attrs?.importedId;
182+
if (!candidateId) continue;
183+
if (id === null) {
184+
id = candidateId;
185+
from = pos;
186+
to = pos + node.nodeSize;
187+
} else if (candidateId === id) {
188+
to = Math.max(to, pos + node.nodeSize);
189+
}
190+
}
191+
});
192+
return id !== null ? { id, from, to } : null;
193+
})();
194+
if (!commentInfo) throw new Error('No comment-marked range found in fixture');
195+
196+
let dispatchCount = 0;
197+
const originalDispatch = view.dispatch.bind(view);
198+
view.dispatch = (tr: unknown) => {
199+
dispatchCount += 1;
200+
return originalDispatch(tr);
201+
};
202+
203+
let toggles = 0;
204+
const observer = new MutationObserver((muts) => {
205+
muts.forEach((m) => {
206+
if (m.attributeName !== 'class') return;
207+
const oldVal = String(m.oldValue ?? '');
208+
const newVal = String((m.target as Element).className ?? '');
209+
if (oldVal === newVal) return;
210+
const before = oldVal.includes('track-change-focused');
211+
const after = newVal.includes('track-change-focused');
212+
if (before !== after) toggles += 1;
213+
});
214+
});
215+
const pages = document.querySelector('.presentation-editor__pages');
216+
if (pages) {
217+
observer.observe(pages, {
218+
attributes: true,
219+
attributeOldValue: true,
220+
subtree: true,
221+
attributeFilter: ['class'],
222+
});
223+
}
224+
225+
try {
226+
// Tx 1: explicit activation, mirrors the sidebar-click path.
227+
editor.commands.setActiveComment({ commentId: commentInfo.id });
228+
229+
// Tx 2: non-collapsed selection that wraps the comment range, mirrors the
230+
// NodeSelection from `presentation.navigateTo` on an SDT wrapper.
231+
const SelectionCtor = view.state.selection.constructor as any;
232+
view.dispatch(view.state.tr.setSelection(SelectionCtor.create(view.state.doc, commentInfo.from, commentInfo.to)));
233+
234+
// Sample for 800ms. With the bug, the (id, null) emit pair plus the host re-assert
235+
// produces 200+ toggles/sec; the fix keeps it bounded.
236+
await new Promise((r) => setTimeout(r, 800));
237+
} finally {
238+
observer.disconnect();
239+
view.dispatch = originalDispatch;
240+
}
241+
242+
const activePluginState = view.state.plugins
243+
.map((p: any) => p.getState?.(view.state))
244+
.find((s: any) => s && 'activeThreadId' in s);
245+
246+
return {
247+
dispatchCount,
248+
toggles,
249+
finalActiveThreadId: activePluginState?.activeThreadId ?? null,
250+
expectedActiveCommentId: commentInfo.id,
251+
};
252+
});
253+
254+
await superdoc.waitForStable();
255+
256+
// Without the fix: dispatchCount climbs into the dozens (the host re-asserts on every
257+
// commentsUpdate emit) and toggles climbs into the hundreds. With the fix: bounded.
258+
expect(result.dispatchCount).toBeLessThanOrEqual(15);
259+
expect(result.toggles).toBeLessThanOrEqual(3);
260+
expect(result.finalActiveThreadId).toBe(result.expectedActiveCommentId);
261+
});

0 commit comments

Comments
 (0)