Optional source collaboration
Add shared editing, remote cursors, and a collaborator list when you need them.
Try it together
Type or select text on either side and watch the other. Try independent undo, read-only mode, and reconnecting after offline edits.
This example runs real Yjs through an in-memory channel. Refreshing or leaving clears the content; “Synced” does not mean saved. Open “Integration code” for the complete example. The Activity list below is confirmed by the server extension running in the same-page relay: authors come from the connection, typing merges into one entry and operations such as undo get their own; only the newest 200 entries are kept, with changes for one day. Versions are taken by the server while storing, at most one every 20 seconds, and named versions can be saved too; restoring as participant A updates B's document and records "Restored a version". See MD Editor activity and versions for the integration.
Connect an application
Collaboration is experimental and opt-in. Core, StarterKit, Vue, React, and MD Editor do not include collaboration dependencies by default.
1. Install the packages
Install both packages for Hocuspocus, or only the first for a custom transport.
pnpm add @quirekit/msfe-collaboration @quirekit/msfe-collaboration-hocuspocus
Markdown source is shared. Display mode, theme, and scrolling stay local; your application supplies stable URLs for uploaded images.
2. Create a session
Provide a document ID, service endpoints, and application credentials:
import { sharedSource, mountSharedSource } from './collaboration/session'
const session = sharedSource('document-123', {
url: 'wss://your-service.example/collaboration',
sessionEndpoint: 'https://your-service.example/api/collaboration/open',
token: () => applicationAccessToken(),
})
url carries WebSocket synchronization. Your sessionEndpoint returns document identity, epoch, format, and permissions. A WebSocket URL alone is not enough. Replace the example URLs and credential function.
3. Prepare and mount
Continue in the same file to mount the editor; parent is the mount element. The helper prepares the session first, with an abort signal to cancel preparation when the route exits.
const controller = new AbortController()
const editor = await mountSharedSource(parent, session, controller.signal)
Save this file as collaboration/session.ts alongside the calling code above.
import { createMsfeEditor, type MsfeEditor } from "@quirekit/msfe-core";
import { StarterKit } from "@quirekit/msfe-starter-kit";
import {
Collaboration,
createCollaborationSession,
type CollaborationSession,
} from "@quirekit/msfe-collaboration";
import {
hocuspocusProvider,
type HocuspocusProviderOptions,
} from "@quirekit/msfe-collaboration-hocuspocus";
/**
* @param documentId - Existing application document.
* @param service - Application admission and transport endpoints.
* @returns Host-owned session, retained even if preparation fails.
*/
export function sharedSource(
documentId: string,
service: HocuspocusProviderOptions,
): CollaborationSession {
return createCollaborationSession({ documentId, provider: hocuspocusProvider(service) });
}
/**
* @param parent - Editor mount.
* @param session - Host-owned source and history.
* @param signal - Route cancellation.
* @returns Mounted editor after authoritative preparation.
*/
export async function mountSharedSource(
parent: HTMLElement,
session: CollaborationSession,
signal: AbortSignal,
): Promise<MsfeEditor> {
// Finish admission and loading before allocating an editor.
await session.prepare({ signal });
signal.throwIfAborted();
// Session owns initial content and identity; extensions keep planning source edits.
return createMsfeEditor({
parent,
extensions: [StarterKit, Collaboration.configure({ session })],
});
}
The service creates new documents and seeds their initial content. Do not reinsert initial Markdown on every connection; existing empty documents must stay empty.
Use the existing extensions option
Add Collaboration.configure({ session }) and omit initial markdown. MD Editor, Vue, and React use their existing extension entry points:
<MdEditor :extensions="[Collaboration.configure({ session })]" />
- Prepare before mounting. Render the component after the session is ready. One session binds to one editor at a time.
- Retain the session. Unmounting releases the editor binding; remounting can reuse its source and local undo history.
- Avoid deep reactivity. In Vue, keep the session in a plain variable,
shallowRef, ormarkRaw.
Optional cursors and collaborators
Add CollaborationPresence for named cursors, selections, and online members. Both extensions use the same session:
import { Collaboration } from '@quirekit/msfe-collaboration'
import { CollaborationPresence } from '@quirekit/msfe-collaboration/presence'
const extensions = [
Collaboration.configure({ session }),
CollaborationPresence.configure({
session,
user: { id: 'alice', name: 'Alice', color: '#2563eb' },
onParticipants: peers => { /* Update your collaborator list. */ },
}),
]
onParticipants returns each remote connection's id, user, and activity (editing / viewing). Use QuireAvatar as in the example, or your own member UI. Cursor movement does not refresh the roster; multiple connections from one user remain separate.
- Only the primary selection is broadcast. Positions inside hidden syntax or widgets use a dashed boundary cursor marked with
◇. - Blurring keeps the member online. Disconnecting clears presence; reconnecting rejoins. Remote updates do not move local focus, selection, or scrolling, or reveal source syntax.
- Presence stays out of source, undo, and saved checkpoints. Source-only collaboration allocates no Awareness instance or cursor layer.
Sync, save, and recover
Synced and saved are separate states. When the service supports checkpoints, call await session.save() and show “Saved” only after the durable receipt arrives.
| Task | API |
|---|---|
| Show connection and save status | Read session.state and update UI through session.subscribe(). |
| Reconnect | Call session.prepare() to refresh identity and permissions. |
| Disable offline editing | Set offlineEditing: 'readonly'; the default allows in-memory edits. |
| Undo local changes | Use editor.history; remote edits stay out of the local undo stack. |
| Leave a document | Cancel unfinished preparation, destroy the editor, then close the session. |
controller.abort()
editor.destroy()
await session.close({ mode: 'persist' })
Keep the session after a failed save or close and offer retry or recovery. Without a persistence service, use checkpoint closing; use discard only when the user explicitly abandons the content.
| Field | Meaning |
|---|---|
lifecycle | Preparing, ready, failed, recovery, or closed. |
connection | Connecting, connected, or disconnected. |
permission | Service-granted read/write permission. |
synced | The transport has acknowledged pending updates. |
durability | Persistence state of local edits; unsupported without save support. |
- Typing during a save preserves dirty state; deletions also need saving. Do not substitute sync events or timers for a durable receipt.
- An epoch change or revoked write permission on an unsaved branch enters recovery. Retain
session.checkpoint()before deciding how to compare or import it. Persistent browser offline storage is not installed. close({ mode: 'checkpoint', persistRecovery })waits for the application to save a recovery copy containing binary identity and source. Unmounting does not discard content.- Toolbars can read
editor.history.canUndoandcanRedo, and observe changes withsubscribe(). Commands, shortcuts, and browser undo share one history. - Remount for a different document. An explicit
documentIdmust match the session.replaceDocument()returnssource-provider-locked; normal commands can still insert content. defineState().invertcurrently reportsunsupported-history-effectwith collaboration history. Do not wireonChangeto a full-source overwrite or another Yjs binding.
Custom transports and current limits
The service owns authentication, write permissions, room limits, and persistence. The example's read-only switch, member names, and colors are client UI; they do not replace server checks.
Implement CollaborationProviderFactory to open an authoritative manifest, synchronize the session's Y.Doc, report connection and permission changes, and remove listeners on disconnect. saveCheckpoint is optional.
For cursors, acquirePresence() returns CollaborationPresenceTransport: clientId, getStates(), publish(), subscribe(), and release(). Install presence only when the provider supports it. The example's presence.ts supplies a complete in-memory channel; Hocuspocus acquires and withdraws its channel on mount and destruction, cleaning up timers.
adoptCollaborationSession() borrows a prepared Y.Doc with a static manifest. The host owns initialization, connections, permissions, and disposal; the session does not destroy the borrowed document. Use a provider factory for dynamic connection reports and durable receipts.
Two-person editing and remote presence have been verified in Chromium. System IMEs, Safari/Firefox, larger participant groups, and production database recovery still need release acceptance.