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 exposes
window.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 updates
What 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
user: { id: "u-1", name: "Alice", color: "#2563eb" }
});
</script>
React
import { useEffect, useRef } from "react";
import { RichTextEditorComponent } from "@richscripts2/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 internal
RichTextEditorCloud/CrdtEngine/ workspace. Public API surface is exported via
window.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