Comment navigation and deep links
How CommentsProvider keeps one active comment address that makes comments addressable, revealable, and linkable.
Navigation is one address, kept by CommentsProvider. Comments below it are addressable, revealable, and linkable without any wiring:
import {CommentsProvider} from '@sanity/collaboration/ui'
<CommentsProvider currentUser={me} getUser={getUser}>
<Threads />
</CommentsProvider>That is the whole of it for a panel that only has to remember what you clicked: the address lives in the provider's React state, and useActiveComment gives anything below it setActiveComment. Two panels that should not share a selection get a provider each.
Deep links are the same provider with your router passed in. From there, navigation is the split between the URL, which is yours, and everything that follows from it, which is the package's. A pasted link should open the panel on the comment it names, even when that comment is a reply. It should also scroll to the comment, expand it out from behind Show 3 more, and select its thread. The rest of this page explains where the line between the two sits, and why it sits there.
You own a value. Where the comment ID lives in your URL, subscribing to it, writing it back when the selection moves, and building the absolute URL for Copy link.
The package owns everything downstream of that value. Matching it against the threads you passed in, holding it pending while the data loads, expanding collapsed replies, scrolling the comment into view, moving focus to the card that holds it, and selecting that thread.
This diagram could not be rendered.
flowchart TB
url["Your URL: ?comment=c-1"] --> parse["parseCommentAddress(params)"]
parse --> prop["activeComment, on CommentsProvider"]
prop --> match{"Is it on screen?"}
match -->|"yes"| reveal["Expand replies, scroll, focus, select the thread"]
match -->|"no, and the list has settled"| notfound["onActiveCommentNotFound"]
notfound --> widen["You widen the filter, or switch tab"]
widen --> match
click["The user selects a card"] --> change["onActiveCommentChange"]
change --> write["withCommentAddress(params, address)"]
write --> url
The wiring for each router is in Link to a comment.
Two modes
The address works the way a React input's value does. The mode is decided the same way: by whether the value prop is rendered at all. Leave activeComment off and the provider keeps the address in state; render it and your router owns it instead.
import {parseCommentAddress} from '@sanity/collaboration'
import {CommentsProvider} from '@sanity/collaboration/ui'
<CommentsProvider
currentUser={me}
getUser={getUser}
activeComment={parseCommentAddress(params)}
onActiveCommentChange={(next) => writeItToYourUrl(next)}
// `undefined` reads both statuses.
onActiveCommentNotFound={() => setStatus(undefined)}
>
<Threads />
</CommentsProvider>setStatus here is your own state for the read, a useState<CommentStatus | undefined>('open') passed to useCommentThreads as status.
activeComment is a CommentAddress, or undefined for none. Rebuilding it on every render is fine. A reveal happens once per address, keyed on the ID rather than on object identity. Re-parsing your URL every render does not scroll every render.
onActiveCommentChange fires when the selection moves, which is what makes the URL follow a click. Either way, no CommentThread needs selected or onSelect: selecting a comment writes the address, and the address selects the comment.
Presence is what decides it, not value. A URL that names no comment parses to undefined. Reading that as "uncontrolled" would have the provider change hands every time the selection is cleared. That is why activeComment={undefined} is a controlled address of nothing. Switching between the two modes after mount logs a warning in development, the same as React does for inputs. Only one of the two can be the source of truth.
onActiveCommentChange on its own does not hand over control. The provider still keeps the address and reports it to you as well, the way an uncontrolled input still fires onChange. That is useful for logging a selection, or syncing it somewhere that is not the source of truth.
For the local mode there is defaultActiveComment, the initial address, which is a way to open a panel on a particular comment without taking on the URL.
The address names the comment that was clicked, a reply included, so a click and that reply's Copy link agree. A thread card selects itself for any comment it holds, so the card's own visual state is the same either way.
Who writes the address
The unit of selection writes it. A CommentThread sets the active comment itself, so its onSelect is a notification you can hang something else off: it runs alongside the write, not instead of it.
An inline highlight is the other way round. InlineCommentHighlight is presentational and writes nothing; PortableTextComments hands it the write as its onSelect. The state a renderHighlight of your own receives carries that same onSelect, already wired, so a highlight of your own only has to call it.
Comment is the exception. A comment is not a unit of selection, because it lives in a thread, so Comment's own onSelect only reports the click. A layout that does make a lone comment selectable calls setActiveComment from useActiveComment itself.
The active comment
Active is the comment the address names: at most one on the surface, and what activeComment holds. Every comment on screen registers itself as a landing site, so an address that names one of them reveals it and an address that names none of them is reported as missing.
When nothing matches the address
The address a link carries is often for a comment your panel is not currently showing: it was resolved and you filter on open, or it lives on another tab. CommentList can tell that nothing on screen matched, but it knows nothing about your filters, so it hands the address back through onActiveCommentNotFound. Widen what you are showing and the pending address lands on the next render.
It fires once per address, so becoming as permissive as you can in one step is the only way the comment lands. Widening by half means the second half never happens.
CommentList is what reports the data settled, because a list is where "this is all the data there is" is known. A lone CommentThread claims and reveals its comment but never declares the data complete, so with no list on screen a stale ID is your own lookup coming back empty. A surface rendering its own thread layout reports it with useActiveCommentSettled; without that call the address stays pending and the host is never told.
What to do in each of those cases is in Link to a comment.
Passing the address in
Nothing here reads window.location, and that is why you pass a value. history.pushState fires no event, so a component watching the location would miss your router's own navigation, and on the server there is no window to watch. Your router is the only thing that can observe its location, so it hands the result down.
parseCommentAddress and withCommentAddress are a convention rather than an interface. They take URLSearchParams and default to a comment parameter, which covers the common case. Put the ID in a path segment instead and you skip both, building the address yourself:
import type {CommentAddress} from '@sanity/collaboration'
const activeComment: CommentAddress | undefined = id
? {type: 'comment', commentId: id}
: undefinedFrom the document to the panel
Clicking a commented range in the editor is the same value going the other way. The highlights @sanity/collaboration draws set the active comment when clicked, and read it back to draw the thread the panel has selected in its active tone. So under one CommentsProvider, the editor and the panel agree without a line of code between them. See Inline comments.
For a surface of your own, such as a highlight you render yourself or a marker in a sidebar, the same pair is a hook away:
const {activeComment, setActiveComment} = useActiveComment()One verb covers every move. A comment ID is the short form of the address it builds:
setActiveComment(comment.id) // jump to it: scroll, expand, focus
setActiveComment(comment.id, {reveal: false}) // select it where it already is
setActiveComment(undefined) // nothing is active{reveal: false} is what a click on a card already on screen passes, so the surface does not move out from under the pointer. Pass a CommentAddress instead of an ID when you already have one. The hook throws outside a CommentsProvider: useActiveComment must be called inside a <CommentsProvider>.
Going back the other way, onActiveCommentChange is the single event to listen to for scrolling your editor to a thread the panel selected.
If you render your own comment markup rather than Comment, useNavigableComment returns the ref to scroll to and whether this is the active comment. Whatever carries that ref has to be focusable, because a reveal that cannot focus what it scrolled to leaves the keyboard behind.
Where to go next
- The wiring, per router: Link to a comment.
- The hooks a custom layout needs: Hooks.
- Clicking a highlight, and what it selects: Inline comments.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?