diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 34efc7c107..cb1f0093c3 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -30,16 +30,18 @@ import { buildDispatchTable } from '../src/invoke/invoke.js'; * implementation, so it is intentionally excluded from the RPC * dispatch surface and the CLI command catalog. Direct calls through * `editor.doc.ranges.scrollIntoView()` are still supported. - * - `selection.onChange` is a subscription primitive (push-based, no - * request/response shape) rather than a request-response operation, - * so it is not represented in `OPERATION_DEFINITIONS` / schemas / - * dispatch. Direct calls through `editor.doc.selection.onChange()` + * - `selection.onChange`, `comments.onChange` are subscription primitives + * (push-based, no request/response shape) rather than request-response + * operations, so they are not represented in `OPERATION_DEFINITIONS` / + * schemas / dispatch. Direct calls through + * `editor.doc.selection.onChange()` and `editor.doc.comments.onChange()` * are still supported. */ const META_MEMBER_PATHS = [ 'invoke', 'ranges.scrollIntoView', 'selection.onChange', + 'comments.onChange', ...REFERENCE_OPERATION_ALIASES.map((alias) => alias.memberPath), ]; diff --git a/packages/document-api/src/comments/comments.test.ts b/packages/document-api/src/comments/comments.test.ts index 24063f41b7..94e5d996af 100644 --- a/packages/document-api/src/comments/comments.test.ts +++ b/packages/document-api/src/comments/comments.test.ts @@ -20,6 +20,7 @@ const stubAdapter = () => goTo: mock(() => ({ success: true })), get: mock(() => ({ commentId: 'c1', status: 'open' })), list: mock(() => ({ items: [], total: 0 })), + onChange: mock(() => () => {}), }) as any; describe('executeCommentsCreate validation', () => { diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts index ec4bbe8445..9fcc35febd 100644 --- a/packages/document-api/src/comments/comments.ts +++ b/packages/document-api/src/comments/comments.ts @@ -118,6 +118,13 @@ export interface CommentsDeleteInput { commentId: string; } +/** + * Listener invoked when the comments collection changes (create, patch, + * delete, document reload). Receives no payload — call `list()` to read + * fresh state. + */ +export type CommentsChangeListener = () => void; + /** * Engine-specific adapter that the comments API delegates to. */ @@ -144,6 +151,12 @@ export interface CommentsAdapter { get(input: GetCommentInput): CommentInfo; /** List comments matching the given query. */ list(query?: CommentsListQuery): CommentsListResult; + /** + * Subscribe to comments-collection changes. Returns an unsubscribe + * function. Implementations should debounce to at most once per tick + * so multi-step transactions and document load don't storm consumers. + */ + onChange(listener: CommentsChangeListener): () => void; } /** @@ -161,6 +174,15 @@ export interface CommentsApi { delete(input: CommentsDeleteInput, options?: RevisionGuardOptions): Receipt; get(input: GetCommentInput): CommentInfo; list(query?: CommentsListQuery): CommentsListResult; + /** + * Subscribe to comments-collection changes. Listener fires whenever + * a comment is created, patched, deleted, or the document reloads. + * + * Listener receives no payload — call `comments.list()` inside the + * callback to read fresh state. Returns an unsubscribe function; + * call it in cleanup (React `useEffect` return, Vue `onUnmounted`). + */ + onChange(listener: CommentsChangeListener): () => void; } const CREATE_COMMENT_ALLOWED_KEYS = new Set(['target', 'text', 'parentCommentId']); diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index ff63295362..6392e6f881 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -102,6 +102,7 @@ function makeCommentsAdapter(): CommentsAdapter { status: 'open' as const, })), list: mock(() => ({ evaluatedRevision: 'r1', total: 0, items: [], page: { limit: 0, offset: 0, returned: 0 } })), + onChange: mock(() => () => {}), }; } @@ -2187,6 +2188,44 @@ describe('createDocumentApi', () => { }); }); + describe('comments.onChange wiring', () => { + function makeApiWithCommentsOnChange() { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + return { api, commentsAdpt }; + } + + it('forwards listener registration to the comments adapter and returns its unsubscribe', () => { + const { api, commentsAdpt } = makeApiWithCommentsOnChange(); + const unsubFromAdapter = mock(() => {}); + // Override the default stub so we can assert the returned unsubscribe identity. + commentsAdpt.onChange = mock(() => unsubFromAdapter); + + const listener = () => {}; + const unsub = api.comments.onChange(listener); + + expect(commentsAdpt.onChange).toHaveBeenCalledTimes(1); + // The listener passed through to the adapter should be the same identity + // — not a wrapped function — so adapter implementations can dedupe. + expect((commentsAdpt.onChange as any).mock.calls[0][0]).toBe(listener); + // The unsubscribe handle returned to the caller should be the adapter's. + expect(unsub).toBe(unsubFromAdapter); + }); + }); + describe('comments.patch target validation', () => { function makeApi() { return createDocumentApi({ diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index a30988b4a3..d5d027a5d7 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -80,6 +80,7 @@ import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comme import type { CommentsAdapter, CommentsApi, + CommentsChangeListener, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -1982,6 +1983,9 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { list(query?: CommentsListQuery): CommentsListResult { return executeListComments(adapters.comments, query); }, + onChange(listener: CommentsChangeListener): () => void { + return adapters.comments.onChange(listener); + }, }, insert(input: InsertInput, options?: MutationOptions): SDMutationReceipt { return executeInsert(adapters.selectionMutation, adapters.write, input, options); diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 5fb7718f46..9a36b7858d 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -77,6 +77,7 @@ function makeAdapters() { status: 'open' as const, })), list: mock(() => ({ evaluatedRevision: '', total: 0, items: [], page: { limit: 50, offset: 0, returned: 0 } })), + onChange: mock(() => () => {}), }; const writeAdapter: WriteAdapter = { write: mock(() => ({ diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index faa870bff2..123b00669d 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -199,6 +199,7 @@ function makeCommentsAdapter() { ], page: { limit: 1, offset: 0, returned: 1 }, })), + onChange: mock(() => () => {}), }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index a79f834f4e..7361728fda 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -12,6 +12,7 @@ import type { AddCommentInput, CommentInfo, CommentsAdapter, + CommentsChangeListener, CommentsListQuery, CommentsListResult, EditCommentInput, @@ -977,6 +978,42 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment // Adapter factory // --------------------------------------------------------------------------- +/** + * Subscribe to comments-collection changes. Forwards the editor's + * `commentsUpdate` event to the listener with no payload — consumers + * call `comments.list()` to read fresh state. + * + * Coalesces bursts to one notification per microtask so multi-step + * transactions and DOCX reload don't storm consumers. Returns an + * unsubscribe function. + */ +function subscribeToCommentsChanges(editor: Editor, listener: CommentsChangeListener): () => void { + let scheduled = false; + let active = true; + const handler = () => { + if (scheduled || !active) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + if (!active) return; + try { + listener(); + } catch { + // Listener errors are not propagated. Subscribers manage their + // own error handling; we don't want one buggy listener to wedge + // the editor's event loop or block other listeners. + } + }); + }; + + editor.on?.('commentsUpdate', handler); + + return () => { + active = false; + editor.off?.('commentsUpdate', handler); + }; +} + export function createCommentsWrapper(editor: Editor): CommentsAdapter { return { add: (input: AddCommentInput, options?: RevisionGuardOptions) => addCommentHandler(editor, input, options), @@ -994,5 +1031,6 @@ export function createCommentsWrapper(editor: Editor): CommentsAdapter { goTo: (input: GoToCommentInput) => goToCommentHandler(editor, input), get: (input: GetCommentInput) => getCommentHandler(editor, input), list: (query?: CommentsListQuery) => listCommentsHandler(editor, query), + onChange: (listener: CommentsChangeListener) => subscribeToCommentsChanges(editor, listener), }; }