Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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, []);
Expand Down
Loading