Comments & versions
The two REST-backed hooks — their full shape, which operations update optimistically and which refetch, error handling, and why they keep working while the socket is down.
useComments and useVersions are the client's half of the data that lives beside the document
rather than inside it. Both go over REST, both load on mount, and both expose a refresh() you
control.
useComments
const {
comments, // CollabComment[] — oldest first
loading,
error,
create,
resolve,
remove,
refresh,
} = useComments({ docName, space: 'text' })Omit space to get every space; pass one to scope the list to a single surface. Changing space
refetches.
await create({
space: 'text',
anchor: { kind: 'text-range', start: 120, end: 160, selectedText: 'the sentence' },
body: 'rephrase this',
authorName: currentUser.name,
})
There is no `userId` in that call. The route attributes the comment to whoever the request
authenticated as and ignores a body-supplied author, so asking the component for one would only be
asking it to supply a value that is thrown away.
await resolve(comment.id) // resolve
await resolve(comment.id, false) // reopen
await remove(comment.id)The three write operations differ in how the list updates, and the difference is deliberate:
| Operation | Local list | Why |
|---|---|---|
create | refetched | the server assigns the id and timestamps — the row you get back is the truth |
resolve | updated in place | only resolvedAt changes, and you already know its new value |
remove | updated in place | the row is gone; there is nothing to learn from a refetch |
So create costs a round trip and resolve/remove feel instant. If you need
create to feel instant too, render an optimistic placeholder and let the refetch replace it.
resolve and remove are author-or-elevated on the server: you may always mutate your own
comment, and mutating someone else's needs canWrite on the document — canComment alone answers
403. Since both update the local list optimistically, a rejection leaves the sidebar briefly
showing a change that did not happen; refresh() in the catch puts it back.
A comment anchor is yours to build from your editor's selection:
function useCommentOnSelection(editor: Editor, docName: string) {
const { create } = useComments({ docName, space: 'text' })
return (body: string) => {
const { from, to } = editor.state.selection
return create({
space: 'text',
anchor: {
kind: 'text-range',
start: from,
end: to,
selectedText: editor.state.doc.textBetween(from, to),
},
body,
})
}
}Store selectedText: offsets in a document other people are editing drift, and the quote is what
lets the sidebar still show what a comment was about. See
Comments.
useVersions
const { versions, loading, error, create, restore, refresh } = useVersions({ docName })
const version = await create('before review') // label is optional
await restore(versions[0].id)Both writes refetch, because both change the list. create adds a row with a server-assigned seq;
restore adds one too — the state it replaced, saved as restored from #<seq> before the old
content is applied.
Render seq rather than id: it is the friendly 1, 2, 3… the library maintains for exactly this.
{versions.map((version) => (
<li key={version.id}>
<strong>Version {version.seq}</strong>
{version.label && <em> — {version.label}</em>}
<time>{new Date(version.createdAt).toLocaleString()}</time>
<button onClick={() => restore(version.id)}>Restore</button>
</li>
))}Restoring is immediate and visible to everyone
restore applies the old content to the live document, so every connected client sees it
happen — including someone mid-sentence. Put a confirmation in front of the button.
You do not need to snapshot first: restoreVersion writes the state it is about to replace into
the history itself, labelled restored from #<seq> and attributed to the caller, so the restore is
already undoable. Call create('before restore') beforehand only when you want your own label on
that checkpoint.
They work when sync does not
Both hooks are REST-backed, and that is all they are: neither one opens a document session or holds
a Y.Doc. They take the document name, they call the routes, and they never touch the WebSocket. A
reconnecting editor still renders its comment sidebar and its history — and so does a page that
never mounts an editor at all, which is what makes a comment count in a document list cheap.
The flip side: they are not live. Someone else's new comment shows up when you refresh().
Trigger it from whatever signal you already have — a poll, an SSE stream, a flag in the shared
document that everyone is watching anyway:
// The document is already a broadcast channel — use it.
useEffect(() => {
const flag = doc.getMap('meta')
const onChange = () => void refresh()
flag.observe(onChange)
return () => flag.unobserve(onChange)
}, [doc, refresh])Errors
Both hooks expose error rather than throwing, and both keep the last good list while a refetch
fails — so a transient error does not blank the sidebar. Failures from the write operations
do reject, so await create(...) in a try/catch is the right shape for a form.
REST failures are CollabRestError, carrying status and the response body:
import { CollabRestError } from '@adonis-agora/collaboration-client'
try {
await create({ /* … */ })
} catch (cause) {
if (cause instanceof CollabRestError && cause.status === 403) {
toast('You do not have permission to comment on this document.')
}
}On resolve and remove, 403 means something narrower — the comment is someone else's and you
do not have canWrite — and 404 means the id is not in this document. Both are worth their own
message; a generic "something went wrong" on a permission answer is the kind of error people file
a bug about.
Sessions
One Y.Doc and one transport per document name — how a session starts, what each status means, how reconnection with fresh tokens works, and why the doc reference is stable across renders.
Editors
useCollabEditor and the adapter seam — how a scene maps into a document key, the three built-in adapters, writing your own, and when to drop down to the raw Y.Doc instead.