plitejs/yjs connects a Plite editor to a Yjs document. The app owns the
provider, document loading, room initialization, authentication, persistence,
and transport lifetime. The binding owns admission, canonical change
translation, and optional awareness projection. 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 the provider in app code. Pass its exact document, an explicit readiness
source, and optional awareness to yjs. The provider never becomes an editor
resource.
import { createEditor } from "plitejs";
import { yjs } from "plitejs/yjs";
import { history } from "plitejs/history";
const editor = createEditor({
plugins: [
history(),
yjs({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
awareness: provider.awareness,
rootName: "plitejs",
}),
],
initialValue,
});import { createEditor } from "plitejs";
import { yjs } from "plitejs/yjs";
import { history } from "plitejs/history";
const editor = createEditor({
plugins: [
history(),
yjs({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
awareness: provider.awareness,
rootName: "plitejs",
}),
],
initialValue,
});The plugin adds a live yjs service to editor.api.
const admission = editor.api.yjs.admissionStatus();
const unsubscribe = editor.api.yjs.subscribeAdmissionStatus(renderStatus);
editor.api.yjs.setCursorData({ name: "Ada" });
editor.api.yjs.syncSelection();const admission = editor.api.yjs.admissionStatus();
const unsubscribe = editor.api.yjs.subscribeAdmissionStatus(renderStatus);
editor.api.yjs.setCursorData({ name: "Ada" });
editor.api.yjs.syncSelection();Give collaborator metadata one runtime validator when the app needs an exact cursor shape. The installed plugin carries that type through its API and React hooks; reads never take a caller-selected generic.
type CursorData = {
color: string;
name: string;
};
const Collaboration = 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,
initialReady: true,
awareness,
});
const editor = createEditor({
plugins: [Collaboration] as const,
initialValue,
});
const cursors = editor.api.yjs.remoteCursors();type CursorData = {
color: string;
name: string;
};
const Collaboration = 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.
doc and initialReady are required. Use initialReady: true only after
local or persistent loading for that document generation is complete. A remote
provider can expose readiness through a small app-local adapter:
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { YjsInitialReadiness } from "plitejs/yjs";
const createHocuspocusInitialReadiness = (
provider: HocuspocusProvider
): YjsInitialReadiness => ({
doc: provider.document,
getSnapshot: () => provider.synced,
subscribe(listener) {
const onSynced = () => listener();
provider.on("synced", onSynced);
let active = true;
return () => {
if (!active) return;
active = false;
provider.off("synced", onSynced);
};
},
});import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { YjsInitialReadiness } from "plitejs/yjs";
const createHocuspocusInitialReadiness = (
provider: HocuspocusProvider
): YjsInitialReadiness => ({
doc: provider.document,
getSnapshot: () => provider.synced,
subscribe(listener) {
const onSynced = () => listener();
provider.on("synced", onSynced);
let active = true;
return () => {
For a normal server-backed room, persist a canonical Yjs update on the server
and omit seed. seed: true is an app assertion that this binding is the one
exclusive initializer for a new empty room. Provider synchronization does not
elect a seed owner. Two peers that independently insert identical initial JSON
can converge to duplicate content.
The binding reports waiting, ready, or error through
admissionStatus(). Document and shared-effect commits are rejected before
publication until admission is ready. After fixing a recoverable import error,
call retryImport().
Install authored before joining the shared document. Every peer installs the same authored schema and resolves its own author ID.
import { authored } from "plitejs/authored";
import { history } from "plitejs/history";
import { yjs } from "plitejs/yjs";
const editor = createEditor({
plugins: [
authored({
authorId: () => session.user.id,
}),
history(),
yjs({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
rootName: "article",
}),
],
initialValue,
});import { authored } from "plitejs/authored";
import { history } from "plitejs/history";
import { yjs } from "plitejs/yjs";
const editor = createEditor({
plugins: [
authored({
authorId: () => session.user.id,
}),
history(),
yjs({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
rootName: "article",
}),
],
initialValue,
});The adapter synchronizes authored review marks with document content and synchronizes the authored context and verdict record sets. Review decisions and remote edits use the ordinary Yjs binding.
Concurrent proposals and decisions converge through the authored marks and verdicts. A blocked decision identifies the additional pending changes to decide together in AuthoredResult.with.
Use Plite Authored Changes for views and decisions.
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. A
document whose schema metadata uses another format fails to open, and the error
names both formats. The
single app-selected binding that uses seed: true records its derived or named
identity atomically with the initial 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({
plugins: [
history(),
yjs({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
rootName: "article",
}),
],
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
},
},
});const editor = createEditor({
plugins: [
history(),
yjs({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(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 plugin.
Install standalone shared effects as keyed editor-plugin 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, definePlugin, defineEffect, valueCodecs } from "plitejs";
import { yjs } from "plitejs/yjs";
const incrementCounter = defineEffect<number>({
persist: { ...valueCodecs.number, version: 1 },
collab: "shared",
collabReplay: "live",
key: "counter.increment",
});
const counterEffects = definePlugin("counter-effects", {
effectTypes: [incrementCounter],
});
const editor = createEditor({
plugins: [counterEffects, yjs({ doc, initialReady: true })],
});import { createEditor, definePlugin, defineEffect, valueCodecs } from "plitejs";
import { yjs } from "plitejs/yjs";
const incrementCounter = defineEffect<number>({
persist: { ...valueCodecs.number, version: 1 },
collab: "shared",
collabReplay: "live",
key: "counter.increment",
});
const counterEffects = definePlugin("counter-effects", {
effectTypes: [incrementCounter],
});
const editor = createEditor({
plugins: [counterEffects, yjs({ doc, initialReady: true })],
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,
initialReady: true,
sharedEffectCompaction: {
authorityId: "collaboration-service",
threshold: 256,
},
});yjs({
doc,
initialReady: true,
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.api.yjs.retireSharedEffectPeer(crashedYDocClientId);editor.api.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. A split inserts a cut marker into the line's Yjs text.
The new block holds an empty Yjs text that shows that text from the cut on.
Every letter keeps its Yjs identity, so a peer's concurrent edit to the second
line stays in that line. When a split falls between children, the children
after it move into the new block by placeholder. A join of a cut's two lines
deletes the cut. Inserting an inline element in the middle of a text rewrites
that text, so the binding drops a peer's concurrent edit to it. Wrapping part
of a text in an inline element keeps the text's letters, but a letter a peer
types before the end of the wrapped part at the same time lands after the
element.
Use the React subpath for admission state and remote cursors. Read provider connection state from the provider itself.
import {
useYjsAdmissionStatus,
useYjsRemoteCursor,
useYjsRemoteCursorGeometry,
useYjsRemoteCursorIds,
} from "plitejs/yjs/react";import {
useYjsAdmissionStatus,
useYjsRemoteCursor,
useYjsRemoteCursorGeometry,
useYjsRemoteCursorIds,
} from "plitejs/yjs/react";useYjsAdmissionStatus(editor) supplies a stable snapshot whose state is
waiting, ready, or error. Keep a mounted Editable read-only until it is ready and
set aria-busy while it waits.
Remote cursor UI reads stable membership with
useYjsRemoteCursorIds, one cursor with useYjsRemoteCursor, and exact-view
geometry with useYjsRemoteCursorGeometry(editor, clientId, { editableRef }).
These hooks require a binding configured with awareness.
Use editor.api.yjs.subscribeRemoteCursors(listener) to invalidate custom
remote selection paint only when resolved remote cursors change. Read the
current snapshot with editor.api.yjs.remoteCursors(). The subscription
returns idempotent cleanup; retire it with the owning view.
Provider packages stay out of plitejs/yjs. Your app chooses Hocuspocus,
WebSocket, WebRTC, IndexedDB, or a custom provider. It passes only the exact
Y.Doc, readiness source, and optional awareness capability into the binding.
The Hocuspocus example observes provider.synced through an app-local
readiness adapter and keeps connection, authentication, room naming,
persistence, cleanup, and server scaling outside the Plite package. Before
disconnecting a shared provider, call clearSelection(); after reconnecting,
call syncSelection().
YjsUpdatePolicy remains public for host-owned adapters that import a
canonical DocumentChange without installing the built-in Yjs binding:
import { DocumentChange } from "plitejs";
import { YjsUpdatePolicy } from "plitejs/yjs";
editor.update(YjsUpdatePolicy.remote, (tx) => {
tx.changes.apply(DocumentChange.fromJSON(message.change));
});import { DocumentChange } from "plitejs";
import { YjsUpdatePolicy } from "plitejs/yjs";
editor.update(YjsUpdatePolicy.remote, (tx) => {
tx.changes.apply(DocumentChange.fromJSON(message.change));
});The policy marks the commit as a remote Yjs import so history and outbound subscriptions apply the same lifecycle rules as the built-in binding.
Change, selection, awareness, provider, multi-root, and remote-cursor geometry
contracts live under packages/plitejs/test/yjs. The plitejs partition runs
them with the useYjs type contract. The Plate partitions run the YjsPlugin
API contract in packages/platejs/src/yjs and the React integration contract in
packages/platejs/test/yjs.
pnpm --filter plitejs test:partition:yjs
pnpm --filter platejs test:partition:yjs
pnpm --filter platejs test:partition:yjs-reactpnpm --filter plitejs test:partition:yjs
pnpm --filter platejs test:partition:yjs
pnpm --filter platejs test:partition:yjs-reactRun 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 Plite History undo/redo across offline and remote
edits, and a claimed schema room that stays atomic across rejected peers and
reconfiguration. The Plate collaboration example covers independent providers,
remote cursors, reconnect, history, schema rejection and recovery, a 390px-wide
viewport, and teardown. The viewport check covers layout, not mobile devices.
The Hocuspocus row starts a local test server
(tooling/plite/donor/yjs/hocuspocus-server.ts) and opens two peers in
manual-connect mode. It checks that each editor stays read-only and discards
typing until admission, that peers converge after connecting, that they converge
again after one peer edits offline and reconnects, and that undo/redo converges. It does not test a
production Hocuspocus server, authentication, persistence, or deployment
availability; those remain host-app infrastructure.