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.
A session is everything the client needs for one document: a Y.Doc, a transport, a status, and
a reconnect loop. The provider keeps one per document name and hands the same instance to every hook
that asks for it.
const { doc, collabDoc, status, synced, error } = useCollabDoc({ docName: 'researches/42/writing' })Shared, and reference counted
Three components on the same page asking for researches/42/writing get one session: one
socket, one document, one set of listeners.
The session is reference counted. Each hook that mounts holds it open; when the last one
unmounts the socket closes — but the Y.Doc stays in the provider's map, with everything typed
into it:
first hook mounts → token fetched, transport built
another hook mounts → same session, nothing new opens
last hook unmounts → transport destroyed, reconnect timer cleared, Y.Doc kept
it mounts again → fresh token, new transport, same Y.Doc — resumed, not restartedSo navigating away and back does close the socket, and coming back reconnects onto the document you left. Unmounting is not free (a token request and a handshake), but it is also not a data loss, and nothing is left holding a socket open behind a page nobody is looking at.
The doc reference is stable
doc is the same Y.Doc object on every render for as long as the provider lives — across
unmount/remount and across every reconnect. That is what makes
Collaboration.configure({ document: doc }) safe to pass into a useEditor config. You do not
need to memoize it, and you should not key an editor on it.
Documents accumulate in the provider's map, one per document name visited. That is the point — it is what makes a remount resume — but it also means the provider is the lifetime to design around: mount it in the layout, not in a page. A provider that remounts on navigation throws its map away and every route change starts from an empty document.
session.destroy() is the terminal call, for a non-React frontend or a test: it destroys the
Y.Doc too, and evicts the session from the provider's map so it is never handed back.
Do not call destroy() from a component
An earlier version of useCollabDoc destroyed the session in its effect cleanup. Two things went
wrong. The Hocuspocus provider queues callbacks that touch the Y.Doc after teardown, and a
destroyed document made them throw — which drives Tiptap's Collaboration extension into React
error #185. And the destroyed session stayed in the map, so the remount got a corpse back that
could never reconnect: connecting forever, with no error to show for it. Let the reference count
do it.
collabDoc is the same document behind the engine-agnostic CollabDoc seam (read, write,
onUpdate) — that is what the editor adapters and useExcalidrawSync consume. useExcalidrawSync
also accepts the raw doc, and wraps it for you.
Starting up
Opening a document is a sequence, and status is how you watch it:
'connecting' → token requested, transport being built
'connected' → socket open — not the same as "the document is here"
'disconnected'→ socket closed; a reconnect is scheduled
'error' → the token could not be fetched, or the handshake was refusedThe session starts lazily, on the first mounted consumer, and starting is idempotent — ten
components calling useCollabDoc for the same name produce one token request.
synced is the one to gate the first paint on. status === 'connected' only says the socket is
open; synced says the server's initial state has actually been applied to the Y.Doc:
const { doc, status, synced, error } = useCollabDoc({ docName })
const editor = useEditor({
extensions: [StarterKit.configure({ undoRedo: false }), Collaboration.configure({ document: doc })],
editable: true,
})
if (error) return <DocumentUnavailable error={error} />
if (!synced) return <DocumentLoading />Not for cosmetics: doc is handed back immediately, before the server's state arrives. Typing into
it during connecting produces local operations that merge with the incoming state — which is the
CRDT behaving correctly, and rarely what the user meant.
Do not flip `editable` off on a disconnect
Gating editable on the live connection — editable: status === 'connected' — reads well and
behaves badly: a WebSocket that drops for four seconds silently turns the editor read-only
mid-sentence, with no explanation. Yjs is built for offline edits; let people keep typing and show
the connection state next to the title instead. Gate on synced for the initial load, when
there is genuinely nothing to edit yet, and leave it alone after that.
On engines whose transport cannot separate the two (partykit, partyserver, a test double),
synced falls back to reporting connected — never less informative than the status.
Skipping the first token request
The token request and the socket are serialized: nothing of the document can arrive until the first
finishes. When the server rendered the page it already knew the answer, so it can just say it —
issueCollaborationToken in the controller, initialTokens on the provider:
<CollaborationProvider initialTokens={{ [docName]: collabToken }}>
<Editor docName={docName} />
</CollaborationProvider>useCollabDoc({ docName, token }) accepts one directly as well. Either way the session builds its
transport at mount with no HTTP request in front of it.
The token is used once. Everything below still happens exactly as it did: a reconnect fetches a fresh token, and a pre-issued token that is malformed or already expired is ignored in favour of the ordinary fetch — a stale page prop costs the first paint, never the connection. See Token flow.
Reconnecting, and why the token is refetched
Tokens expire. So on a disconnect the session does not retry the same socket — it tears the transport down and rebuilds it with a fresh token:
disconnected → wait 1s → new token → new transport
→ wait 2s → …
→ wait 4s → …
→ capped at 15s
→ a successful connection resets the backoff to 1sThe side effect is the useful part: every reconnect re-runs your authorize. Revoke someone's
access while they are connected and it takes effect on their next reconnect, with no reload and no
separate revocation channel.
error holds the last failure and stays set while the loop retries, so you can show "reconnecting,
last error: …" without wiring your own state.
Awareness
useAwareness reads the same session's ephemeral peer state:
const { peers, status, awareness, setLocalState } = useAwareness({
docName,
user: { userId: me.id, name: me.name, avatarUrl: me.avatarUrl },
})
// peers: [{ clientId, userId, name, avatarUrl }] — everyone except you
// awareness: the channel object itself, or null before the transport connectsPublish before you read. peers is built from the user field of everyone else's awareness
state, so if nobody publishes one, everybody sees a room full of anonymous client ids. The user
option does it for you; setLocalState is the general form, for a cursor position or a selection
alongside the identity:
setLocalState({ user: { userId: me.id, name: me.name }, cursor: { x, y } })Whatever you last published is re-applied to the channel after every reconnect — the transport is
rebuilt with a new Awareness each time, and without that you would quietly vanish from everyone
else's presence list on the first dropped socket.
Two views of one channel, for two different jobs:
peersis a snapshot — re-read on every awareness change, and what you render an avatar stack or a presence list from.awarenessis the channel — what an editor extension needs in order to publish the local cursor and subscribe to remote ones. Tiptap'sCollaborationCarettakes aproviderand readsprovider.awareness, so{ awareness }is a complete provider as far as it is concerned. See Tiptap collaboration.
The hook re-renders on every awareness change: someone joining, leaving, or moving a cursor. That is
frequent by design — this is the cursor channel — so render peers into something cheap, and do not
run effects off it that hit the network.
awareness is null until the transport connects, and it is rebuilt on every reconnect. Key
anything that holds onto it on the object itself, or a reconnected session leaves your extension
publishing into an abandoned channel.
Awareness is per-process, not global
peers is everyone connected to the same document room on the same server process. For the
cross-instance answer — "is anyone in this document, anywhere" — ask the server's
presence roster.
Below the hook
DocSession is exported, for a non-React frontend or a test:
import { DocSession } from '@adonis-agora/collaboration-client'
const session = new DocSession('researches/42/writing', { baseUrl: 'https://api.example.com' })
const unsubscribe = session.subscribe(() => console.log(session.getStatus()))
await session.start() // idempotent
session.doc // the Y.Doc
session.engine // what the server said, once the token arrived
session.synced // initial state applied, not just "socket open"
const release = session.retain() // hold it open (what the hooks do on mount)
release() // last release closes the transport, keeps the doc
session.stop() // same teardown, forced
session.destroy() // terminal: also destroys the Y.Doc, evicts from the mapsubscribe starts the session too, so a subscriber never misses the first transition. retain is
what makes the lifetime work: it returns its own release, so an effect can return session.retain()
and never double-release.
Testing without a server
createTransport on the provider replaces the transport factory wholesale — the token is still
fetched (stub fetchImpl to avoid that), but nothing opens a socket:
<CollaborationProvider
baseUrl="http://test"
fetchImpl={fakeFetchReturningAToken}
createTransport={(info, doc) => new FakeTransport(doc)}
>See Testing.
Client hooks
The React package — the provider and its config, the hooks and which one to reach for, why the client never hard-codes an engine, and how sessions are reference counted.
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.