@platejs/yjs connects a Plite editor to a Yjs document. Use it when your app
owns the collaboration transport and wants Plite document changes, selection state,
awareness, and provider lifecycle to share one Yjs source of truth. Plite
History owns undo and redo; canonical history replay uses the same Yjs bridge
as every editor change.
Install the adapter with the Plite packages your editor already uses.
Install your transport package separately. For Hocuspocus, add the provider at the app edge.
Create or wrap a provider as a YjsProviderLike, then pass it to
yjs. The provider owns the network. Plite owns the editor
adapter.
import { createEditor } from "@platejs/plite";
import { yjs } from "@platejs/yjs";
import { history } from "@platejs/plite-history";
const editor = createEditor({
extensions: [
history(),
yjs({
clientId: "local-user",
doc,
provider,
rootName: "@platejs/plite",
}),
],
initialValue,
});import { createEditor } from "@platejs/plite";
import { yjs } from "@platejs/yjs";
import { history } from "@platejs/plite-history";
const editor = createEditor({
extensions: [
history(),
yjs({
clientId: "local-user",
doc,
provider,
rootName: "@platejs/plite",
}),
],
initialValue,
});The extension adds a yjs group to state and tx.
const connected = editor.read((state) => state.yjs.connected());
editor.update((tx) => {
tx.yjs.sendSelection(selection, { name: "Ada" });
});const connected = editor.read((state) => state.yjs.connected());
editor.update((tx) => {
tx.yjs.sendSelection(selection, { name: "Ada" });
});Give collaborator metadata one runtime validator when the app needs an exact cursor shape. The installed extension carries that type through state, updates, and React hooks; reads never take a caller-selected generic.
type CursorData = {
color: string;
name: string;
};
const YjsExtension = yjs({
cursorData: {
validate: (value): value is CursorData =>
typeof value === "object" &&
value !== null &&
"color" in value &&
typeof value.color === "string" &&
"name" in value &&
typeof value.name === "string",
},
doc,
});
const editor = createEditor({
extensions: [YjsExtension] as const,
initialValue,
});
const cursors = editor.read((state) => state.yjs.remoteCursors());type CursorData = {
color: string;
name: string;
};
const YjsExtension = yjs({
cursorData: {
validate: (value): value is CursorData =>
typeof value === "object" &&
value !== null &&
"color" in value &&
typeof value.color === "string" &&
"name" in value &&
typeof value.name === "string",
},
Invalid remote metadata is omitted from the cursor while its selection remains available. Invalid local metadata is rejected before it reaches awareness.
Each collaboration document records one non-null compiled schema identity for its primary and named roots. An editor joins only when its schema kind, fingerprint, and named lineage fields match. A nonempty Yjs document without schema metadata fails closed instead of guessing how to decode its content. The first editor that seeds an empty document records its derived or named identity atomically with the document.
Install the final compiled schema before joining a collaboration document. A claimed, populated room keeps that schema identity for its lifetime; local reconfiguration cannot rewrite the room in place. Run an explicit versioned document migration before joining a new or offline room, then connect every peer with the migrated schema identity. Changing schema semantics without a version bump is rejected.
rootName names one collaboration document, not only its primary root. The
adapter stores primary children at rootName and named roots in the associated
${rootName}:roots registry. Create the editor with the complete
EditorDocumentValue; named roots synchronize even when no React root view is
mounted.
const editor = createEditor({
extensions: [
history(),
yjs({
doc,
provider,
rootName: "article",
}),
],
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
},
},
});const editor = createEditor({
extensions: [
history(),
yjs({
doc,
provider,
rootName: "article",
}),
],
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
},
},
});One Plite commit that changes several roots becomes one Yjs transaction and one Plite history batch. Remote batches fit the complete document through the compiled schema, including shared and exclusive content-root ownership. Awareness selections carry their root key; ranges whose anchor and focus belong to different roots are rejected.
State fields declared with collab: "shared" synchronize through keyed Yjs
effects. Their descriptors are registered with the editor extension.
Install standalone shared effects as keyed editor-extension resources so every peer can decode the same key and codec version. History and Yjs discover the installed descriptors. Shared effect values must encode to JSON-compatible data.
import {
createEditor,
defineExtension,
defineEffect,
valueCodecs,
} from "@platejs/plite";
import { yjs } from "@platejs/yjs";
const incrementCounter = defineEffect<number>({
codec: valueCodecs.number,
collab: "shared",
collabReplay: "live",
key: "counter.increment",
});
const counterEffects = defineExtension("counter-effects", {
effectTypes: [incrementCounter],
});
const editor = createEditor({
extensions: [counterEffects, yjs({ doc })],
});import {
createEditor,
defineExtension,
defineEffect,
valueCodecs,
} from "@platejs/plite";
import { yjs } from "@platejs/yjs";
const incrementCounter = defineEffect<number>({
codec: valueCodecs.number,
collab: "shared",
collabReplay: "live",
key: "counter.increment",
});
const counterEffects = defineExtension("counter-effects", {
effectTypes: [incrementCounter],
});
const editor = createEditor({
Every standalone shared effect declares collabReplay. Use "live" for an
event delivered once to active peers. Use "latest" only for an absolute,
idempotent restore value. Shared state-field transitions use "latest"
automatically and checkpoint the field's current absolute value. A "live"
effect must not be the sole source of durable shared state. A custom "latest"
effect must define collabSnapshot(state) to capture that absolute value;
compaction never treats the last event as a state snapshot.
Configure one peer as the effect-log compaction authority when the shared document retains a long-running session.
yjs({
doc,
sharedEffectCompaction: {
authorityId: "collaboration-service",
threshold: 256,
},
});yjs({
doc,
sharedEffectCompaction: {
authorityId: "collaboration-service",
threshold: 256,
},
});Configure exactly one stable authority identity for each collaboration document and
reuse it when that authority restarts with a new Y.Doc client generation. Each
"live" event records the peers active in its causal Yjs state; durable peer
acknowledgements preserve an unknown-codec retry across reconnects without
admitting late joiners. Once every live recipient acknowledges a prefix, the
authority writes its checkpoint, per-source watermarks, and prefix deletion in
one Yjs transaction. Late joiners restore "latest" values before the remaining
tail; expired "live" events do not replay. Checkpoint restoration is atomic,
so a missing checkpoint codec keeps the checkpoint and every later event
pending until the descriptor is installed. Installing the descriptor retries
the pending checkpoint without waiting for more Yjs traffic.
Compaction preserves delivery rather than guessing that a silent peer is dead. Graceful teardown publishes an inactive acknowledgement. When the host knows a peer crashed permanently, the authority can retire that Y.Doc client generation explicitly:
editor.update((tx) => {
tx.yjs.retireSharedEffectPeer(crashedYDocClientId);
});editor.update((tx) => {
tx.yjs.retireSharedEffectPeer(crashedYDocClientId);
});The durable tombstone releases only that generation's delivery obligation. A
returning collaborator joins with a fresh Y.Doc client generation, restores
"latest" state from the checkpoint, and never receives the retired live
tail. A different stable authority identity is rejected. Intentional ownership
transfer belongs to an explicit host-fenced document migration.
Document changes and effects produced by one Yjs transaction enter Plite in one trusted remote update. Reapplying the same Yjs update does not replay an effect twice.
Remote Yjs event batches compile against the synchronized mirror into
root-scoped DocumentChange ranges and apply with tx.changes.apply(...).
Routine imports decode only touched top-level nodes. Unsupported projected
content or root metadata takes an explicit traced full-diff fallback.
Outbound commits lower commit.changes directly into affected Yjs regions.
Compatible nodes and uniquely derived relocated subtrees keep their Yjs node
identities; structural regions use the canonical change as their only
execution authority. Canonical split changes lower through bounded affected
ranges.
Use the React subpath for provider state and remote cursors.
import {
useYjsProviderStatus,
useYjsProviderSynced,
useYjsRemoteCursors,
} from "@platejs/yjs/react";import {
useYjsProviderStatus,
useYjsProviderSynced,
useYjsRemoteCursors,
} from "@platejs/yjs/react";useYjsRemoteCursorDecorationSource converts remote selections into a Plite
decoration source. useYjsRemoteCursorOverlayPositions resolves overlay
geometry when the mounted editor can provide DOM rects.
Provider packages stay out of @platejs/yjs. Your app chooses Hocuspocus,
WebSocket, WebRTC, IndexedDB, or a custom provider and maps it into
YjsProviderLike.
The Hocuspocus example wraps provider.document as doc, forwards provider
events, and keeps authentication, room naming, persistence, and server scaling
outside the Plite package.
The package is covered by change, selection, awareness, provider, React, and
structural soak contracts under packages/yjs/test.
pnpm --filter @platejs/yjs testpnpm --filter @platejs/yjs testRun the focused Chromium rows for editor convergence, the Plate collaboration example, and the app-owned Hocuspocus boundary:
pnpm --filter plite test:plite-browser:chromium \
tests/plite-browser/donor/examples/yjs-collaboration.test.ts \
tests/plite-browser/donor/examples/collaboration-demo.test.ts \
tests/plite-browser/donor/examples/yjs-hocuspocus.test.tspnpm --filter plite test:plite-browser:chromium \
tests/plite-browser/donor/examples/yjs-collaboration.test.ts \
tests/plite-browser/donor/examples/collaboration-demo.test.ts \
tests/plite-browser/donor/examples/yjs-hocuspocus.test.tsThe collaboration row covers remote edits, offline work, reconnect, and Plite History undo/redo. The Plate collaboration example covers independent providers, cursor rendering, schema recovery, mobile layout, and teardown. The Hocuspocus row creates distinct provider-backed pages in manual-connect mode and proves that unsynced documents reject local publication. It does not test a production Hocuspocus server, authentication, persistence, or deployment availability; those remain host-app infrastructure.