From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
UNPUBLISHED
  • Overview
  • Why This Fork
  • Examples
Walkthroughs
  • Installing Plite
  • Adding Event Handlers
  • Defining Custom Elements
  • Applying Custom Formatting
  • Executing Commands
  • Saving to a Database
  • Canonical Change Substrate
  • Improving Performance
Concepts
  • Interfaces
  • Nodes
  • Locations
  • Transforms
  • Document Changes
  • Commands
  • Editor
  • Extensions
  • Rendering
  • Serializing
  • Normalizing
  • Using TypeScript
  • Roots
  • Document State
  • Editing Behavior
  • Selection And DOM
  • Clipboard And Paste
  • Projection And Overlays
  • Schema
API
  • Anchor API
  • Location API
  • Path API
  • PointEntry API
  • Point API
  • Range API
  • Selection API
  • Location Types APIs
  • Span API
  • Editor
  • Element API
  • NodeEntry API
  • Node API
  • Node Types APIs
  • Text API
  • Debug Value Scrubbing
  • Transforms API
Libraries
  • Plite DOM
  • History Editor API
  • History Extension Setup
  • History
  • Plite History
  • Plite Hyperscript
  • Plite Layout
  • Annotations
  • DOM Coverage Boundaries
  • Editable Component
  • Plite React Event Handling
  • Virtualized Rendering
  • Plite React Hooks
  • React Editor Setup
  • React Editor
  • Plite React
  • Plite Component
  • Plite Yjs
  • Plite
General
  • Migration
  • Contributing
  • Docs Proof Map
  • FAQ
  • Resources

Plite Yjs

PreviousNext

Connect a Plite editor to Yjs document meta, awareness, and provider lifecycle.

@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

Install the adapter with the Plite packages your editor already uses.

pnpm add @platejs/yjs yjs @platejs/plite @platejs/plite-react @platejs/plite-history react react-dom
pnpm add @platejs/yjs yjs @platejs/plite @platejs/plite-react @platejs/plite-history react react-dom

Install your transport package separately. For Hocuspocus, add the provider at the app edge.

pnpm add @hocuspocus/provider
pnpm add @hocuspocus/provider
Plite ComponentPlite

On This Page

InstallCreate The ExtensionSchema IdentityMulti-Root DocumentsShared EffectsReact HooksProvider BoundaryProofRelated Docs
Build your editor
Production-ready AI template and reusable components.
Get all-access

Create The Extension

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.

Schema Identity

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.

Multi-Root Documents

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.

Shared Effects

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.

React Hooks

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 Boundary

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.

Proof

The package is covered by change, selection, awareness, provider, React, and structural soak contracts under packages/yjs/test.

pnpm --filter @platejs/yjs test
pnpm --filter @platejs/yjs test

Run 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.ts
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.ts

The 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.

Related Docs

  • Canonical Change Substrate
  • Plite History
  • Plite React
doc,
});
const editor = createEditor({
extensions: [YjsExtension] as const,
initialValue,
});
const cursors = editor.read((state) => state.yjs.remoteCursors());
extensions: [counterEffects,
yjs
({ doc })],
});