Skip to content

spike: Yjs CRDT integration for collaborative editing#2250

Draft
kmelve wants to merge 7 commits intomainfrom
spike/yjs-crdt-integration
Draft

spike: Yjs CRDT integration for collaborative editing#2250
kmelve wants to merge 7 commits intomainfrom
spike/yjs-crdt-integration

Conversation

@kmelve
Copy link
Member

@kmelve kmelve commented Feb 26, 2026

Summary

Spike exploring Yjs as a CRDT collaboration layer for the Portable Text Editor. Instead of forwarding patches between editors, this uses a shared Y.Doc that keeps editors in sync via Yjs's conflict-free merge semantics.

Core implementation

  • Slate→Yjs translator (apply-to-yjs.ts): Translates all Slate operation types (insert_text, remove_text, insert_node, remove_node, set_node, split_node, merge_node, move_node) into Y.XmlText mutations
  • Yjs→Slate translator (apply-to-slate.ts): Converts Yjs observeDeep events back into Slate operations for remote changes
  • Bidirectional conversion (convert.ts): Converts between Slate node trees and Y.XmlText delta format
  • withYjs plugin (with-yjs.ts): Buffers local ops during apply, flushes to Y.Doc on onChange, observes remote changes via observeDeep
  • Playground integration: Toggle for Yjs sync mode, shared Y.Doc provider

What works

  • Basic text editing syncs (insert, delete, cursor movement)
  • Enter key (block splitting) syncs
  • Backspace at block boundaries (block merging) syncs
  • Concurrent typing in the same block

What's next (this PR)

  • Battle-test marks/decorators, inline objects, block objects, styles, lists
  • Add concurrent editing gherkin scenarios
  • Y.Doc tree viewer (inspector tab)
  • Operation log (inspector tab)
  • Latency simulator for demoing CRDT conflict resolution

Test plan

  • Unit tests pass: npx vitest --run --project unit src/yjs/
  • Browser tests pass: pnpm --filter @portabletext/editor test:browser:chromium
  • Manual test: Enable Yjs Sync toggle in playground, type in both editors

🤖 Generated with Claude Code

Adds a Yjs collaboration layer that syncs Slate operations between
editors via a shared Y.Doc instead of patch forwarding.

Core implementation:
- Slate→Yjs translator (apply-to-yjs.ts) for all operation types
- Yjs→Slate translator (apply-to-slate.ts) for remote changes
- Bidirectional conversion between Slate nodes and Y.XmlText (convert.ts)
- withYjs plugin that buffers local ops and observes remote changes
- Playground toggle for Yjs sync mode with shared Y.Doc provider

Includes unit tests for basic operations and browser tests for
concurrent editing scenarios.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Feb 26, 2026

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

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Error Error Feb 27, 2026 3:47pm
portable-text-example-basic Error Error Feb 27, 2026 3:47pm
portable-text-playground Error Error Feb 27, 2026 3:47pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Feb 26, 2026

⚠️ No Changeset found

Latest commit: 7e94228

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

kmelve and others added 3 commits February 25, 2026 23:12
Unit tests for:
- Marks: add, remove, and multiple marks on text nodes
- Inline objects: insert/remove stock-ticker inside blocks
- Block objects: insert image block at root
- Styles: h1→normal, normal→blockquote changes
- Lists: add/remove listItem and level attributes
- Conversion roundtrip for mixed text+inline and list blocks

Gherkin scenarios for:
- Concurrent typing in different blocks
- One editor types while the other presses Enter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stage 5 - Y.Doc Tree Viewer:
- New `yjs-tree-viewer.tsx` subscribes to `sharedRoot.observeDeep`
  and renders a live, collapsible tree of the Y.XmlText CRDT structure
- Color-coded: block types (blue), keys (gray), attributes (purple/amber),
  text content (green)
- Appears as "Y.Doc" tab in inspector when Yjs Sync is enabled

Stage 6 - Operation Log:
- Added `onOperation` callback to `YjsEditorConfig` type
- `with-yjs.ts` calls it in both directions: local→Yjs (after flush)
  and Yjs→Slate (after remote apply)
- New `yjs-operation-log.tsx` with provider + display component
- Direction badges ("→ Yjs" blue / "→ Slate" green), expandable op details
- Appears as "Ops" tab in inspector when Yjs Sync is enabled

Infrastructure:
- Moved `YjsProvider` up to wrap both editors and inspector
- Exported `YjsContext` for tree viewer access
- Wrapped with `YjsOperationLogProvider` for shared log state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stage 7 - Latency Simulator:
- New `yjs-latency-provider.tsx`: creates per-editor Y.Doc instances
  that sync via `Y.Doc.on('update')` → `setTimeout` → `Y.applyUpdate`
- Configurable delay (0–2000ms) via slider in header
- When latency > 0, each editor gets its own Y.Doc with delayed sync
  instead of the shared Y.Doc, demonstrating CRDT conflict resolution
- When latency = 0, editors share a single Y.Doc (original behavior)

Changes:
- `feature-flags.ts`: add `yjsLatency: number` to PlaygroundFeatureFlags
- `playground-machine.ts`: add `set yjs latency` event
- `header.tsx`: add latency slider (TimerIcon + range input), visible
  only when Yjs Sync is enabled
- `yjs-plugin.tsx`: accept `editorIndex` and `useLatency` props,
  switch between shared and per-editor Y.Docs
- `editor.tsx`: pass `editorIndex` and `useLatency` to PlaygroundYjsPlugin
- `editors.tsx`: wrap with LatencyYjsProvider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes:

1. Latency provider: register cross-sync handlers synchronously during
   doc creation (useMemo) instead of in useEffect. React fires child
   effects before parent effects, so editors were connecting and pushing
   content to independent Y.Docs before any cross-sync handlers existed.
   Also use a ref for latencyMs to avoid recreating docs when the slider
   moves.

2. with-yjs: move `editor.normalize()` inside `withoutPatching` and
   `pluginWithoutHistory` in both `handleYjsObserve` and `connect()`.
   Nodes created from Yjs deltas lack `_key` properties. Normalization
   adds keys via `set_node` ops, which previously generated patches
   targeting undefined values because patching was still active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a new "CRDT" inspector tab that visualizes Yjs sync between editors:
- Status bar showing converged/diverged state with in-flight count
- Packet lanes showing animated dots with countdown for in-flight updates
- Event log with color-coded send/deliver entries and latency annotations

The latency provider now tracks in-flight updates and exposes a
subscription API for CRDT events via context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add offline/online toggle per editor to simulate network disconnection
in the Yjs playground. Offline editors stop relaying Y.Doc updates;
reconnecting triggers a state-vector sync to merge diverged content.

Also simplifies YjsPlugin by removing the redundant YjsProvider/YjsContext
in favor of the LatencyYjsProvider, and uses a closure-local `isConnected`
flag in `withYjs` instead of the public `isYjsConnected` property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@socket-security
Copy link

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedhusky@​9.1.71001006280100
Addedeslint-formatter-gha@​1.6.0951007679100
Addeddel-cli@​6.0.01001007981100
Addeddebug@​4.4.310010010083100
Addedemojilib@​4.0.210010010083100
Addedglobals@​15.15.01001008594100
Addedeslint@​9.39.19010010097100
Addedjsdom@​27.2.09810010094100
Addedeslint-plugin-react-refresh@​0.4.2410010010094100
Addedeslint-plugin-react-hooks@​7.0.110010010096100

View full report

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