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

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 internal RichTextEditorCloud/CrdtEngine/ workspace. Public API surface is exported via window.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