Per-node Yjs CRDT engine
Architecture and integration reference for the textSync: "crdt" mode shipped in v2.2. For end-to-end usage, see the collaboration demo.
What this mode is
editor.collab.attach({ doc, provider, textSync: "crdt" }) activates a per-paragraph Yjs binding that mirrors the editable DOM into a Y.XmlFragment in your shared Y.Doc. Two users editing the same paragraph at the same time produce non-conflicting Yjs operations: each insert / delete / format range carries a Yjs vector clock, and the CRDT merges them deterministically. No last- write-wins, no caret jumps under normal load, no proxy server required — the engine runs entirely in the browser and pairs with any Yjs provider (y-websocket, Hocuspocus, y-webrtc, Liveblocks, or any other CRDT-compatible transport).
Mode matrix
textSync | Routes to | Behaviour |
|---|---|---|
false (default) | presence-only no-op handle | Awareness / cursors only; no doc sync. Use when content lives elsewhere (e.g. server-stored HTML). |
true (legacy) | snapshot-mode MVP | Shared Y.Text holds the editor’s HTML as one opaque string. Coarse merge — last-write-wins on same-paragraph conflicts. Preserved for back-compat with v2.0 customers; deprecated 2027. |
"crdt" (recommended) | per-node engine (this page) | Full character-level CRDT. Concurrent same-paragraph typing merges; presence cursors via Y.RelativePosition; per-author Y.UndoManager. |
Architecture
The engine ships as a separate plugin script (richtexteditor/plugins/crdt-engine.min.js, ~88 KB minified) that exposeswindow.RichTextEditorCrdt. The yjscollab plugin’s collab.attach method probes for that global; if present, it delegates content sync to the engine while retaining ownership of the awareness overlay, presence panel, and review-ledger bridge.
Browser editable (rte.js)
↓ MutationObserver
[crdt-engine.js]
↓ Y.Doc transactions (LOCAL_ORIGIN)
Y.XmlFragment
↓ Yjs provider (y-websocket / Hocuspocus / etc.)
Network
↓
Other peers' Y.Docs
Other peer's Y.Doc updates
↓ ydoc.on("update", origin !== LOCAL_ORIGIN)
[crdt-engine.js renders to DOM,
preserves selection via path-based capture]
↓
Browser editable updatesWhat the engine owns
- Y.XmlFragment ↔ DOM bidirectional sync. Block elements, leaf elements, and inline marks all round-trip. Mid-tree childList changes (lists, tables) trigger surgical subtree rebuilds rather than coarse whole-doc re-renders.
- Selection preservation. Path-based capture before each remote-driven re-render, restored after — the user’s caret stays in place when collaborators type elsewhere.
- Echo prevention. Local edits get tagged
LOCAL_ORIGIN; remote handler ignores them.MutationObserver.takeRecords()is drained synchronously after each remote render so the observer’s async callback can’t loop the local-edit emission back into the Y.Doc. - Cursor presence. Selection positions encoded as
Y.RelativePositionbytes — stable identifiers that survive concurrent inserts / deletes — published on the awareness channel; peers decode on receipt to project remote cursors onto their local DOM. - Per-author undo.
Y.UndoManagerwithtrackedOrigins: { LOCAL_ORIGIN }— Ctrl-Z undoes only the local user’s edits, never their collaborator’s.
What the host owns
- Toolbar, menus, dialogs, paste handling.
- Yjs provider connection / authentication (the engine doesn’t talk to the network).
- Awareness payload composition (user identity, colour, name).
- Cursor overlay painting — the engine returns position data, the host paints.
- Conflict UX (showing collaborator names on hover, highlighting their selection rectangles, etc.).
Integration code
Vanilla JS
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/richtexteditor/plugins/crdt-engine.min.js"></script>
<script type="module">
import * as Y from "https://esm.sh/yjs";
import { WebsocketProvider } from "https://esm.sh/y-websocket";
const editor = new RichTextEditor("#editor", {
commentsEnabled: true,
trackChangesEnabled: true,
});
const ydoc = new Y.Doc();
const provider = new WebsocketProvider("wss://your-yjs-server", "doc-1", ydoc);
editor.collab.attach({
doc: ydoc,
provider: provider,
textSync: "crdt", // ← per-node CRDT
requireCrdt: true, // fail closed if the engine is missing
user: { id: "u-1", name: "Alice", color: "#2563eb" }
});
</script>Verify the attached mode
Production integrations should check the mode after attach. requireCrdt: true keeps presence and the review ledger available when the engine cannot start, but it does not silently switch document edits to the legacy snapshot mode. Use onCollabStatus for central telemetry or a visible recovery message.
const editor = new RichTextEditor(host, {
onCollabStatus(status) {
if (status.fallbackReason) reportCollaborationIssue(status.fallbackReason);
}
});
const status = editor.collab.getStatus();
if (status.textSyncMode !== "crdt") {
reportCollaborationIssue(status.fallbackReason);
}React
import { useEffect, useRef } from "react";
import { RichTextEditorComponent } from "@richscripts/richtexteditor/react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
export function CollabEditor({ docId, user }) {
const editorRef = useRef();
useEffect(() => {
const editor = editorRef.current.getEditor();
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
"wss://your-yjs-server", docId, ydoc,
);
editor.collab.attach({
doc: ydoc, provider, textSync: "crdt", user,
});
return () => { provider.destroy(); ydoc.destroy(); };
}, [docId]);
return (
<RichTextEditorComponent
ref={editorRef}
config={{ commentsEnabled: true, trackChangesEnabled: true }}
/>
);
}Migrating from textSync: true
Two paths, depending on whether you want a hard cutover or a side-by-side rollout.
Path A — greenfield (recommended)
Generate a fresh Y.Doc keyed differently (e.g. append "-crdt" to your existing doc id). On first attach for a given doc, run the migration helper from the engine to populate the new document from your stored HTML:
const newDoc = new Y.Doc();
window.RichTextEditorCrdt.htmlToYFragment({
ydoc: newDoc,
html: legacyHtmlString,
parserDocument: document,
});
editor.collab.attach({
doc: newDoc,
provider: new WebsocketProvider(url, docId + "-crdt", newDoc),
textSync: "crdt",
});Path B — side-by-side cutover
Run both modes in parallel during a feature-flag rollout. Important: all collaborators on a given doc must use the same mode; mixing legacy snapshot and CRDT modes on the same Y.Doc produces inconsistent state. Tenant-scope the flag.
const mode = featureFlag("rte_crdt_for_tenant_" + tenantId) ? "crdt" : true;
editor.collab.attach({ doc: ydoc, provider, textSync: mode });Deprecation timeline (provisional)
| Date | Event |
|---|---|
| Q2 2026 | textSync: "crdt" ships behind opt-in flag; textSync: true remains the default (no breaking change). |
| Q4 2026 | textSync: "crdt" becomes the default; textSync: true still works via the legacy back-compat shim. |
| Q2 2027 | textSync: true emits a deprecation warning at runtime. |
| Q4 2027 | textSync: true removed. Customers must be on "crdt". |
Engineering details
Source layout, test corpus, and contributor docs live alongside the engine in the internalRichTextEditorCloud/CrdtEngine/ workspace. Public API surface is exported viawindow.RichTextEditorCrdt.*:
attachCollab(opts)— the dispatcher; routes bytextSyncmodeattachCrdtBinding(opts)— direct binding entry (skip the dispatcher)attachAwareness(opts)— cursor presence wiringattachUndoManager(opts)— per-author Y.UndoManager wrapperhtmlToYFragment(opts)— one-shot HTML → per-node Y migration helperLOCAL_ORIGIN— symbol used to tag local-origin Yjs transactions for echo prevention