feat(editor): wire optional ydoc + Collaboration extension (TASK-1258) (#456)

* feat(editor): wire optional ydoc + Collaboration extension into Editor.svelte (TASK-1258)

Productionizes the Yjs binding pattern verified in the
feat/yjs-tiptap-spike branch (TASK-1245 sandbox). Editor.svelte now
accepts an optional `ydoc?: Y.Doc` prop; when set, it registers the
Tiptap Collaboration extension and disables StarterKit's undoRedo
so the y-tiptap binding takes over document state + history.

Editor.svelte changes:

- Imports: `Collaboration` from `@tiptap/extension-collaboration`,
  type-only `Y.Doc` from `yjs` (the actual constructor lives in the
  caller route — TASK-1260 wires the WS provider).
- Props: new optional `ydoc?: Y.Doc`. When undefined the editor's
  extension list and behaviour are byte-identical to today;
  every existing call site stays backward-compatible.
- Extensions:
    · StarterKit.configure now spreads `{ undoRedo: false }` IFF
      ydoc is set (Yjs owns history when collab is active; v3's
      option is `undoRedo`, the v2 `history` key was renamed).
    · `...(ydoc ? [Collaboration.configure({ document: ydoc,
      field: 'default' })] : [])` slot at the end of the extension
      array — empty when ydoc is undefined, so existing single-Doc
      usage is unaffected.

Dependencies added to web/package.json:

- `yjs@^13.6.30`
- `@tiptap/extension-collaboration@3.22.5` — EXACT pin (no caret)
  because @tiptap/core is on 3.22.5 and the latest published
  3.23.x of extension-collaboration requires a matching core. The
  CLAUDE.md note in TASK-1269 codifies the multi-package coordinated
  bump rule.
- `@tiptap/y-tiptap@^3.0.3`

Out of scope (per task spec): WS provider plumbing (TASK-1260),
lifecycle binding to item-detail page (TASK-1260), first-edit seed
from markdown (TASK-1262), CollaborationCursor extension
(TASK-1264).

Parent: PLAN-1248. Phase 1 — Backend foundation complete; Phase 2
is the next logical block to start.

* fix(editor): gate content-prop sync $effect when ydoc is active per Codex review (round 1)

P1: Editor.svelte's existing $effect that calls
editor.commands.setContent on prop-content changes (item switch,
external REST update) would route through the y-tiptap binding as a
LOCAL ProseMirror change when Collaboration is registered —
overwriting peers' Y.Doc state with stale REST markdown on every
content prop refresh.

Add an early return in the $effect when ydoc is set. Y.Doc is the
authoritative state under collab; markdown→Y.Doc seeding for the
first-edit-on-empty case lives in TASK-1262 and uses Y.Doc's own
primitives, NOT setContent.

The early return also captures `tracker.prev = content` so a future
host route that swapped ydoc on/off mid-editor wouldn't see the
non-collab branch's `prev === undefined` and accidentally skip its
first sync. In practice ydoc is set once per editor mount today,
but the cheap capture keeps the contract honest.
This commit is contained in:
xarmian
2026-05-08 16:46:32 -04:00
committed by GitHub
parent 50e0936b34
commit b4904e0db9
3 changed files with 166 additions and 3 deletions
+109 -1
View File
@@ -12,6 +12,7 @@
"@tiptap/core": "^3.22.5",
"@tiptap/extension-bubble-menu": "^3.22.5",
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-collaboration": "^3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-table": "^3.22.5",
@@ -20,13 +21,15 @@
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@tiptap/y-tiptap": "^3.0.3",
"diff": "^9.0.0",
"dompurify": "^3.4.2",
"lowlight": "^3.3.0",
"mermaid": "^11.14.0",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0"
"tiptap-markdown": "^0.9.0",
"yjs": "^13.6.30"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
@@ -773,6 +776,22 @@
"lowlight": "^2 || ^3"
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.22.5.tgz",
"integrity": "sha512-3rax34HKSo8L5ihv5iGxISNBUIdVP0Fq9O6T/mq4kQOLhVhNbqwr9I/lWOvaz24nPE7u5Vkz0Ii3zWGlzxyPPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5",
"@tiptap/y-tiptap": "^3.0.2",
"yjs": "^13"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz",
@@ -1128,6 +1147,26 @@
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/y-tiptap": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.3.tgz",
"integrity": "sha512-8UvuV4lTisCE9cMTc/X8kRyTn9edUO7Kball0I6wb17VwZSjNDfh/YKtP4O5vcPawEzFHQIvZGq/k1h37kAf0w==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.100"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.7.1",
"prosemirror-state": "^1.2.3",
"prosemirror-view": "^1.9.10",
"y-protocols": "^1.0.1",
"yjs": "^13.5.38"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2418,6 +2457,16 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/katex": {
"version": "0.16.45",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
@@ -2482,6 +2531,27 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -3849,6 +3919,27 @@
"node": ">=8"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
@@ -3890,6 +3981,23 @@
"node": ">=6"
}
},
"node_modules/yjs": {
"version": "13.6.30",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
"integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+4 -1
View File
@@ -35,6 +35,7 @@
"@tiptap/core": "^3.22.5",
"@tiptap/extension-bubble-menu": "^3.22.5",
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-collaboration": "3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-table": "^3.22.5",
@@ -43,12 +44,14 @@
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@tiptap/y-tiptap": "^3.0.3",
"diff": "^9.0.0",
"dompurify": "^3.4.2",
"lowlight": "^3.3.0",
"mermaid": "^11.14.0",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0"
"tiptap-markdown": "^0.9.0",
"yjs": "^13.6.30"
}
}
+53 -1
View File
@@ -5,6 +5,8 @@
import { Plugin } from '@tiptap/pm/state';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import StarterKit from '@tiptap/starter-kit';
import { Collaboration } from '@tiptap/extension-collaboration';
import type * as Y from 'yjs';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
@@ -342,11 +344,29 @@
let {
content = '',
editable = true,
ydoc,
onUpdate,
onEditor,
}: {
content?: string;
editable?: boolean;
/**
* Optional Yjs document to bind this editor to via the Tiptap
* Collaboration extension (PLAN-1248). When set, the y-tiptap
* binding takes ownership of document state and undo/redo —
* StarterKit's history is disabled below so the two systems
* don't fight over keystrokes.
*
* When undefined (the default), the editor behaves exactly as
* it did pre-collab: a single ProseMirror Y-Doc-less Doc with
* StarterKit's built-in undoRedo. This keeps every existing
* call site backward-compatible.
*
* The WebSocket provider that syncs ydoc with the server is
* wired by the editor's host route (TASK-1260); this prop just
* accepts the constructed Y.Doc from there.
*/
ydoc?: Y.Doc;
onUpdate?: (markdown: string) => void;
onEditor?: (editor: Editor) => void;
} = $props();
@@ -488,10 +508,16 @@
const getAttachmentUrl = (uuid: string, variant?: AttachmentVariant) =>
wsSlug ? api.attachments.downloadUrl(wsSlug, uuid, variant) : `pad-attachment:${uuid}`;
// When a Y.Doc is supplied, the Collaboration extension owns
// undo/redo (Yjs maintains its own history that survives peer
// edits correctly) and StarterKit's undoRedo would fight it.
// In Tiptap v3 the option is `undoRedo: false`; v2's `history`
// key was renamed during the v3 migration.
const extensions = [
StarterKit.configure({
codeBlock: false,
link: false, // We use our own SafeLink extension below
...(ydoc ? { undoRedo: false } : {}),
}),
MermaidCodeBlock.configure({
HTMLAttributes: { class: 'code-block' },
@@ -550,6 +576,15 @@
},
}),
AttachmentChip.configure({ getDownloadUrl: getAttachmentUrl, workspaceSlug: wsSlug }),
// When a Y.Doc is provided, register the Collaboration
// extension so the y-tiptap binding takes over document
// state. Without ydoc this slot is empty and the editor
// behaves exactly as it did pre-collab. The `field` option
// defaults to "default" which matches what the WS provider
// (TASK-1260) and tests will use; explicit here so the
// shape is grep-able from a future RedisOpBus / multi-Doc
// path that might want a different field name per item.
...(ydoc ? [Collaboration.configure({ document: ydoc, field: 'default' })] : []),
AttachmentUpload.configure({
upload: async (file) => {
if (!wsSlug) {
@@ -761,9 +796,26 @@
});
});
// Sync content when prop changes (e.g. doc switch, external update)
// Sync content when prop changes (e.g. doc switch, external update).
//
// CRITICAL: when ydoc is set, this path MUST NOT run. Y.Doc is the
// authoritative state under collab; calling editor.commands.setContent
// would route through the y-tiptap binding as a LOCAL ProseMirror
// change and overwrite peers' state with stale REST markdown on
// every prop refresh / item switch. Markdown→Y.Doc seeding for the
// first-edit-on-empty case is TASK-1262's concern; it goes through
// Y.Doc's own primitives, not setContent.
const tracker: { prev: string | undefined } = { prev: undefined };
$effect(() => {
if (ydoc) {
// Capture the initial value so a future ydoc=undefined render
// (host route swapping back to non-collab mode) doesn't see
// `prev === undefined` and skip the first sync. In practice
// the host route doesn't switch ydoc on/off mid-editor today,
// but the cheap update here keeps the contract honest.
tracker.prev = content;
return;
}
if (tracker.prev === undefined) {
// First run: capture initial value without syncing
tracker.prev = content;