diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js index 74df3e32a0..482b34dc65 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js @@ -475,6 +475,7 @@ export const CommentsPlugin = Extension.create({ const editor = this.editor; const isHeadless = editor.options.isHeadless; let shouldUpdate = true; + let pendingSelectedNotification = null; const pluginSpec = { key: CommentsPluginKey, @@ -507,11 +508,25 @@ export const CommentsPlugin = Extension.create({ // Emit commentsUpdate event when active comment changes (e.g., from comment bubble click) // Defer emission to after transaction completes to avoid dispatching during apply() if (previousActiveThreadId !== newActiveThreadId) { + const activeCommentId = newActiveThreadId ?? null; const update = { type: comments_module_events.SELECTED, - activeCommentId: newActiveThreadId ? newActiveThreadId : null, + activeCommentId, }; - setTimeout(() => editor.emit('commentsUpdate', update), 0); + clearTimeout(pendingSelectedNotification); + pendingSelectedNotification = setTimeout(() => { + pendingSelectedNotification = null; + if (editor.isDestroyed) return; + + const editorState = editor.state; + if (!editorState) return; + + const currentActiveCommentId = CommentsPluginKey.getState(editorState)?.activeThreadId ?? null; + // A deferred SELECTED(id) may only publish while id is still the active plugin state. + if (currentActiveCommentId !== activeCommentId) return; + + editor.emit('commentsUpdate', update); + }, 0); } pluginState.activeThreadId = newActiveThreadId; diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.test.js index 6236ae3f07..7aef9b791f 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.test.js @@ -631,6 +631,11 @@ const createPluginStateEnvironment = ({ schema: providedSchema, doc: providedDoc }; editor.view = view; + Object.defineProperty(editor, 'state', { + get() { + return view.state; + }, + }); const pluginView = plugin.spec.view?.(view); return { plugin, editor, view, schema, pluginView }; @@ -656,6 +661,122 @@ describe('CommentsPlugin state', () => { expect(pluginState.activeThreadId).toBe('thread-1'); }); + describe('deferred selected events', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const setActiveComment = (view, activeThreadId) => { + view.dispatch( + view.state.tr.setMeta(CommentsPluginKey, { + type: 'setActiveComment', + activeThreadId, + forceUpdate: true, + }), + ); + }; + + it('only emits the final active thread when several activations are scheduled together', () => { + const { view, editor } = createPluginStateEnvironment(); + + setActiveComment(view, 'comment-1'); + setActiveComment(view, 'comment-2'); + setActiveComment(view, 'comment-3'); + + expect(editor.emit).not.toHaveBeenCalled(); + expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('comment-3'); + + vi.runAllTimers(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: 'comment-3', + }); + expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('comment-3'); + }); + + it('drops a stale activation when a clear is scheduled after it', () => { + const { view, editor } = createPluginStateEnvironment(); + + setActiveComment(view, 'comment-1'); + setActiveComment(view, null); + + vi.runAllTimers(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: null, + }); + expect(CommentsPluginKey.getState(view.state).activeThreadId).toBeNull(); + }); + + it('drops a stale clear when a new activation is scheduled after it', () => { + const { view, editor } = createPluginStateEnvironment(); + + setActiveComment(view, 'comment-1'); + vi.runAllTimers(); + editor.emit.mockClear(); + + setActiveComment(view, null); + setActiveComment(view, 'comment-2'); + + vi.runAllTimers(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: 'comment-2', + }); + expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('comment-2'); + }); + + it('emits the reverted thread once when an activation burst returns to the original id', () => { + const { view, editor } = createPluginStateEnvironment(); + + setActiveComment(view, 'comment-1'); + setActiveComment(view, 'comment-2'); + setActiveComment(view, 'comment-1'); + + vi.runAllTimers(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: 'comment-1', + }); + expect(CommentsPluginKey.getState(view.state).activeThreadId).toBe('comment-1'); + }); + + it('does not schedule a notification when the active thread is reasserted', () => { + const { view, editor } = createPluginStateEnvironment(); + + setActiveComment(view, 'comment-1'); + vi.runAllTimers(); + editor.emit.mockClear(); + + setActiveComment(view, 'comment-1'); + vi.runAllTimers(); + + expect(editor.emit).not.toHaveBeenCalled(); + }); + + it('does not emit when the editor is destroyed before a deferred notification runs', () => { + const { view, editor } = createPluginStateEnvironment(); + + setActiveComment(view, 'comment-1'); + editor.isDestroyed = true; + + expect(() => vi.runAllTimers()).not.toThrow(); + expect(editor.emit).not.toHaveBeenCalled(); + }); + }); + it('stores decorations provided through metadata', () => { const { view } = createPluginStateEnvironment(); const decorations = DecorationSet.create(view.state.doc, []);