Skip to content

spike: @portabletext/plugin-yjs — Yjs CRDT integration at the PT patch level#2265

Draft
christianhg wants to merge 16 commits intomainfrom
spike/plugin-yjs
Draft

spike: @portabletext/plugin-yjs — Yjs CRDT integration at the PT patch level#2265
christianhg wants to merge 16 commits intomainfrom
spike/plugin-yjs

Conversation

@christianhg
Copy link
Member

@christianhg christianhg commented Feb 27, 2026

@portabletext/plugin-yjs — CRDT collaborative editing for PTE

Spike for a Yjs-based collaboration plugin that operates at the PT patch level, not Slate internals.

Architecture

Local edits → editor.on('mutation') → PT patches → Y.Doc transaction
                                                         ↓
Remote edits ← editor.send({patches, snapshot}) ← observeDeep ← Y.Doc

Y.Doc structure:

  • Root Y.XmlFragment contains blocks as Y.XmlText children
  • Each block's Y.XmlText has attributes (_key, _type, style, etc.) and delta content (spans as text inserts with _key, marks attributes)
  • Text edits use Y.Text character-level CRDT merging
  • KeyMap provides O(1) bidirectional _keyY.XmlText lookup

Why PT-level, not Slate-level:

  • PT's _key-based addressing is naturally CRDT-friendly — stable identity under concurrent edits, no offset tracking needed
  • diffMatchPatch carries character-level diffs that map directly to Y.Text
  • 5 patch types × 2 directions = clean translation layer
  • ~500 lines of production code vs the original spike's ~1,200 with known bugs
  • Zero PTE changes — uses only public API (editor.on('mutation'), editor.send())

What's in this PR

Plugin package (packages/plugin-yjs):

  • apply-to-ydoc.ts — PT patches → Y.Doc (all 5 patch types: set, unset, insert, diffMatchPatch, setIfMissing)
  • ydoc-to-patches.ts — Y.Doc observeDeep events → PT patches
  • key-map.ts — bidirectional _keyY.XmlText lookup
  • plugin.ts — lifecycle, initial sync, echo prevention
  • types.ts — shared interfaces

46 tests across 4 files:

  • apply-to-ydoc.test.ts — 15 tests for PT→Y.Doc direction
  • ydoc-to-patches.test.ts — 8 tests for Y.Doc→PT direction
  • roundtrip.test.ts — 8 tests for roundtrip + concurrent editing
  • edge-cases.test.ts — 15 tests for empty blocks, multi-span, span split/merge, block split, concurrent split+edit

Playground integration:

  • Yjs toggle in header
  • Y.Doc tree viewer in inspector
  • Latency simulation for testing
  • Multiple editors sync through shared Y.Doc

Key findings

  1. Yjs preserves span boundaries — different _key attributes prevent Yjs from merging adjacent spans, even with identical marks
  2. Concurrent text edits merge correctly — Y.Text CRDT handles character-level merging (tested: prepend + append converge)
  3. Empty spans are optimized away — Yjs drops empty string inserts from delta; handled by detecting missing spans and inserting fresh
  4. Span splits are clean — PTE's "truncate + insert" pattern maps directly to Y.Doc mutations without any special handling
  5. 19.5 kB bundle — minimal footprint

What's left for production

  • Interactive playground testing
  • markDefs concurrent annotation editing (currently whole-array set)
  • Cursor/selection awareness (Yjs awareness protocol)
  • Network transport layer (WebSocket/WebRTC provider)
  • Undo/redo isolation testing with real PTE

christianhg and others added 2 commits February 27, 2026 16:17
Yjs CRDT integration for the Portable Text Editor that operates at the
PT patch level rather than the Slate operation level.

The plugin translates between PT patches and Y.Doc mutations using the
editor's existing public API:
- editor.on('mutation') for outgoing local patches
- editor.send({ type: 'patches' }) for incoming remote patches

This approach means:
- No Slate internals are touched
- _key-based addressing maps naturally to CRDT structures
- diffMatchPatch carries character-level diffs → Y.Text CRDT merging
- PTE handles all remote change isolation (withRemoteChanges, etc.)

Co-authored-by: Aldus <aldus@miriad.app>
Add Yjs CRDT sync toggle to the playground:
- 'Yjs' toggle in the header enables/disables Yjs mode
- When enabled, patches flow through a shared Y.Doc instead of
  direct broadcast between editors
- Y.Doc tree viewer tab in the inspector panel
- Latency simulation support (per-editor Y.Docs with delayed sync)
- Playground's broadcast patches/value actions are skipped when
  Yjs mode is active

Co-authored-by: Aldus <aldus@miriad.app>
@vercel
Copy link

vercel bot commented Feb 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-example-basic Ready Ready Preview, Comment Feb 28, 2026 8:42am
portable-text-playground Ready Ready Preview, Comment Feb 28, 2026 8:42am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Skipped Skipped Feb 28, 2026 8:42am

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

⚠️ No Changeset found

Latest commit: e58eed1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@christianhg christianhg changed the title feat: add @portabletext/plugin-yjs package spike: @portabletext/plugin-yjs — Yjs CRDT integration at the PT patch level Feb 27, 2026
When Yjs mode is toggled on after content already exists, the Y.Doc
doesn't have the existing blocks. Now on each mutation, ensureBlocksExist
checks the snapshot and adds any missing blocks to the Y.Doc before
applying patches. This fixes typing in pre-existing blocks not being
reflected in the Y.Doc.
Replace the patch log approach with a real Y.Doc structure:

- Root Y.XmlFragment contains blocks as Y.XmlText children
- Each block's Y.XmlText has attributes (_key, _type, style, etc.)
  and delta content (spans as text inserts with _key, marks attributes)
- Text edits use Y.Text character-level CRDT merging
- KeyMap provides O(1) bidirectional _key ↔ Y.XmlText lookup

PT patch → Y.Doc translation handles all 5 patch types:
- diffMatchPatch → parse DMP, compute char-level diff, apply to Y.XmlText
- set → setAttribute or format (for span properties)
- unset → removeAttribute or delete from fragment
- insert → blockToYText + insert into fragment
- setIfMissing → conditional setAttribute

15 unit tests covering blockToYText conversion and all patch types.

Y.Doc → PT patches direction uses snapshot fallback for now,
granular event translation in progress.
8 tests covering:
- PT patch → Y.Doc roundtrips (text edit, style change, insert+unset, marks)
- Concurrent text edits merge via Y.Text CRDT
- Concurrent style changes converge (last-writer-wins)
- Concurrent block insert + text edit both preserved
- Concurrent block delete + text edit both applied

All tests use two Y.Docs synced via Y.applyUpdate to simulate
real collaborative editing scenarios.
Replace the old Y.Array<string> patch log viewer with a proper
XmlFragment tree viewer showing blocks, spans, attributes, and
delta content. Reset function now clears the XmlFragment root.
- Add ydoc-to-patches.ts: translates Y.Doc observeDeep events to PT patches
  - Y.XmlText attribute changes → set/unset patches
  - Y.XmlText delta text changes → diffMatchPatch patches
  - Y.XmlText delta format changes → set patches on span marks
  - Y.XmlFragment structural changes → insert/unset patches for blocks
- Add 8 unit tests for ydocToPatches (ydoc-to-patches.test.ts)
- Wire ydocToPatches into plugin observeDeep handler (replaces snapshot fallback)
- Add @sanity/diff-match-patch dependency for DMP string generation
- Export ydocToPatches from package index

31 tests passing across 3 test files.
Cast ydocToPatches output to Patch[] when passing to editor.send().
The values are JSON-compatible (from Y.Doc attributes) but typed as
unknown in the local interfaces.
11 new edge case tests covering:
- Empty blocks (Yjs optimizes away empty string inserts)
- Multi-span text editing (second span, marks preservation)
- Span insertion between existing spans
- Span deletion from multi-span blocks
- Multiple block insertion
- First block deletion
- Full block replacement via set
- markDefs with link annotations
- setIfMissing for markDefs

Fix: handle empty spans in diffMatchPatch. When a span has empty text,
Yjs optimizes away the delta entry. The first keystroke in an empty
block now correctly creates the delta entry with span attributes.
4 new tests covering real PTE editing patterns:
- Bold selection splits span into two (truncate + insert in one transaction)
- Unbold merges spans back (extend text + delete span)
- Enter key splits block (truncate span + insert new block)
- Concurrent span split + text edit converge correctly

These test multi-patch mutations applied atomically in a single
Y.Doc transaction, which is how the plugin handles real PTE edits.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant