-
Notifications
You must be signed in to change notification settings - Fork 2
Collaboration
Collaboration attaches a native-owned WebSocket transport and Rust-owned Yjs runtime to one room-backed NativeEditorDocumentHandle. The editor, controller, awareness state, retry logic, and outbound document updates all use that same handle. Do not mirror the document into a second JavaScript collaboration store.
The package provides the client side only. Your server must implement the Yjs sync and awareness protocol, seed new rooms during the normal Yjs handshake, and own authentication, authorization, and durable server persistence.
Create a room handle once per room identity, pass it to useYjsCollaboration, and spread the returned bindings onto the editor.
import { useEffect, useMemo } from 'react';
import {
createNativeEditorDocumentHandle,
RichTextEditor,
useYjsCollaboration,
} from '@apollohg/react-native-rich-text-editor';
export function CollaborativeEditor({ documentId }: { documentId: string }) {
const documentHandle = useMemo(
() =>
createNativeEditorDocumentHandle({
initialization: {
type: 'room',
documentId,
lineageId: `my-app|${documentId}`,
},
}),
[documentId]
);
const collaboration = useYjsCollaboration({
documentId,
handle: documentHandle,
transport: {
url: `wss://example.com/yjs?documentId=${encodeURIComponent(documentId)}`,
connect: true,
},
localAwareness: {
userId: 'user-42',
name: 'Ada Lovelace',
color: '#0A84FF',
},
});
useEffect(() => () => documentHandle.destroy(), [documentHandle]);
return <RichTextEditor {...collaboration.editorBindings} />;
}editorBindings supplies the shared documentHandle, the current documentRevision, native-derived remote selection decorations, and focus/blur awareness handlers. It intentionally does not supply valueJSON or a JavaScript onContentChangeJSON loop: the native editor adapter publishes local document and caret changes directly to the same Rust session.
Use a stable lineageId for the lifetime of a logical document history. It is part of the persisted snapshot identity, not a display label.
A room handle has one of two valid starting paths:
| Start | Result |
|---|---|
| No snapshot | The handle is AwaitRemote; state.documentJson and documentRevision are null, and RichTextEditor waits to render until the server's accepted Step 2 initializes the room. There is no local HTML/JSON fallback. |
| Valid room snapshot | The saved CRDT state renders while disconnected, then the normal Yjs handshake reconciles it with the room. |
Do not create a localHtml or localJson handle and then attach collaboration to it. Collaboration transport configuration is accepted only for a room handle. The room's server state, plus a compatible optional room snapshot, is the document source of truth.
state.status maps native transport state to "idle", "disconnected", "connecting", "handshaking", "synchronized", "incompatible", or "destroyed". isConnected is true only for "synchronized"; an open socket that is still handshaking is not ready for editing a remote-initialized room.
The library does not persist documents automatically. Persist an exported snapshot as its metadata JSON plus its original Uint8Array bytes. The metadata contains the snapshot format version, document ID, lineage ID, fragment name, and schema fingerprint; keep it with the byte payload.
saveRoomSnapshot and loadRoomSnapshot below are app-owned async persistence helpers. Implement them against your durable storage; they are not package APIs.
type StoredRoomSnapshot = {
metadataJson: string;
encodedState: Uint8Array;
};
// App-owned persistence boundary; implement these for your storage layer.
declare function saveRoomSnapshot(snapshot: StoredRoomSnapshot): Promise<void>;
declare function loadRoomSnapshot(documentId: string): Promise<StoredRoomSnapshot | null>;
const exported = documentHandle.bridge.snapshotExport();
await saveRoomSnapshot({
metadataJson: exported.metadataJson,
encodedState: exported.encodedState,
});On a later app start, decode your stored metadata and pass both pieces into the new room handle. Do not turn the binary state into a JSON number array, and do not substitute collaboration.state.documentJson for a CRDT snapshot.
const stored = await loadRoomSnapshot(documentId);
const documentHandle = createNativeEditorDocumentHandle({
initialization: {
type: 'room',
documentId,
lineageId: `my-app|${documentId}`,
...(stored
? {
snapshot: {
metadata: JSON.parse(stored.metadataJson),
encodedState: stored.encodedState,
},
}
: {}),
},
});Snapshot export is read-only and is allowed while connected. A snapshot is accepted only when its format version, document ID, lineage ID, fragment name, and schema fingerprint match the receiving room handle. That makes a schema or fragment migration a deliberate data migration, not a transparent client update.
documentHandle.bridge.snapshotRestore() is an exceptional recovery operation, not a normal sync mechanism. It is allowed only while the transport is detached or disconnected and there are no pending local document updates; connected, connecting, handshaking, synchronized, and incompatible sessions refuse it. Prefer reconstructing the handle with the stored snapshot at startup.
localAwareness is optional. When present it publishes the user's identity (userId, name, color) and optional avatarUrl or extra data. It is ambient presence, not durable document data.
collaboration.updateLocalAwareness({
user: { userId: 'user-42', name: 'Ada Lovelace', color: '#5E5CE6' },
focused: true,
});The hook's editor bindings publish focus and blur automatically. Native code holds the local caret as a document-aware cursor, so do not create a second JavaScript selection mirror merely to update awareness. peers exposes the native peer projection, and editorBindings.remoteSelections contains only remote peers with a native cursor; spread it on the editor rather than constructing decorations from arbitrary peer state.
transport is either null (no transport, leaving the handle detached) or:
{
url: 'wss://example.com/yjs?documentId=case-123',
connect: true,
}The URL must be a native-valid ws: or wss: endpoint. Change connect to false or call disconnect() to disable connection intent; call connect() to enable it. reconnect() first retires the active intent, then declares a fresh connected intent.
Use the hook callbacks to observe the projected state, peers, and errors:
useYjsCollaboration({
documentId,
handle: documentHandle,
transport,
onStateChange: (state) => console.info('Collaboration status', state.status),
onPeersChange: (peers) => console.info('Collaboration peer count', peers.length),
onError: (error) => console.error('Collaboration error', error),
});Native code owns physical sockets, while Rust owns Yjs frames, awareness clocks, the bounded outbox, close classification, and retry eligibility. Do not create a JavaScript WebSocket, provide a retry interval, or promise a fixed backoff schedule. Unexpected eligible disconnects are retried by the native/Rust transport; terminal conditions are surfaced through state and onError.
Use a protocolAdapter only when your server requires an application-level handshake before Yjs frames. Native offers the listed subprotocols and blocks Yjs traffic until an adapter callback returns { action: 'ready' }; that can be onOpen or onMessage.
transport: {
url: 'wss://example.com/yjs',
connect: true,
protocolAdapter: {
protocols: ['example-auth-v1'],
timeoutMillis: 10_000,
terminalCloseCodes: [4403],
onOpen: async () => ({
action: 'continue',
frames: [{ type: 'text', data: 'authenticate' }],
}),
onMessage: async (_context, frame) =>
frame.type === 'text' && frame.data === 'authenticated'
? { action: 'ready' }
: { action: 'reject' },
},
}The adapter callbacks run for each physical reconnect, so they can read fresh credentials. terminalCloseCodes marks matching server closes as terminal: the transport parks rather than entering automatic retry. Never persist credentials, frames, or adapter return values in the document snapshot.
Use createYjsCollaborationController() outside React or when an owner needs direct lifecycle methods. Bind its documentHandle, state.documentRevision, peers-derived remote selections, and handleFocusChange() / handleSelectionChange() to any custom editor integration. The standard hook is less error-prone because it owns this binding and destroys its controller on unmount.
For a hook, destroy the document handle in the component owner as shown above. For an imperative controller, call controller.destroy() before handle.destroy(). disconnect() leaves a controller reusable; destroy() detaches its listener and transport and must be treated as final.
Collaboration is an iOS/Android native integration. This package ships no web platform module and does not accept a browser createWebSocket factory. Provide an alternate editor/collaboration path for React Native Web.
For exact public types, see Types and Events Reference and Document API Reference.
The current built-in code-block schema adds a nullable language attribute, which changes its fingerprint. Upgrade room participants together or keep an explicitly compatible schema; do not bypass snapshot or room schema checks. Syntax-highlighting colors are local presentation and need not match between participants. See Schema Customization and Migration Guide.
React Native Rich Text Editor · Documentation · Migration Guide