Documentation

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

textSyncRoutes toBehaviour
false (default)presence-only no-op handleAwareness / cursors only; no doc sync. Use when content lives elsewhere (e.g. server-stored HTML).
true (legacy)snapshot-mode MVPShared 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 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.RelativePosition bytes — 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.UndoManager with trackedOrigins: { 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)

DateEvent
Q2 2026textSync: "crdt" ships behind opt-in flag; textSync: true remains the default (no breaking change).
Q4 2026textSync: "crdt" becomes the default; textSync: true still works via the legacy back-compat shim.
Q2 2027textSync: true emits a deprecation warning at runtime.
Q4 2027textSync: 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 by textSync mode
  • attachCrdtBinding(opts) — direct binding entry (skip the dispatcher)
  • attachAwareness(opts) — cursor presence wiring
  • attachUndoManager(opts) — per-author Y.UndoManager wrapper
  • htmlToYFragment(opts) — one-shot HTML → per-node Y migration helper
  • LOCAL_ORIGIN — symbol used to tag local-origin Yjs transactions for echo prevention