UNPKG

@durable-streams/y-durable-streams

Version:

Yjs provider for Durable Streams - sync Yjs documents over append-only streams

596 lines (478 loc) 18 kB
--- name: yjs-editors description: > Integrate Yjs collaborative editing with TipTap v3 and CodeMirror 6 over durable streams. Canonical React pattern: doc+awareness in useState, provider in useEffect with connect:false (listeners before connect). TipTap: Collaboration + CollaborationCaret extensions, -caret not -cursor package. CodeMirror: yCollab binding. Covers awareness wiring, multi-document navigation with key={docId}, SSR ssr:false requirement. Critical anti-patterns that crash agents documented. type: core library: durable-streams library_version: "0.2.3" requires: - yjs-getting-started sources: - "durable-streams/durable-streams:packages/y-durable-streams/src/yjs-provider.ts" - "durable-streams/durable-streams:examples/yjs-demo/src/routes/room.$roomId.tsx" - "durable-streams/durable-streams:examples/yjs-demo/src/components/yjs-provider.tsx" --- This skill builds on durable-streams/yjs-getting-started. Read it first for install and server setup. # Durable StreamsEditor Integrations Wire Yjs + YjsProvider into rich-text and code editors. Both integrations share the same React lifecycle pattern — the editor-specific code is just the binding setup. ## React lifecycle pattern (shared by all editors) All editor integrations MUST use this pattern. **Key principle:** Doc and awareness are created once via `useState` (stable references). The provider is created in `useEffect` with `connect: false` so that event listeners are attached BEFORE the first network request. This prevents the race condition where `synced` fires between construction and listener attachment. ```typescript import { useState, useEffect, useRef } from "react" import { YjsProvider } from "@durable-streams/y-durable-streams" import * as Y from "yjs" import { Awareness } from "y-protocols/awareness" function CollabEditor({ docId }: { docId: string }) { // 1. Doc + awareness: stable, created once via useState lazy init. // Use setLocalState (not setLocalStateField) because a new // Awareness starts with null state. const [{ doc, awareness }] = useState(() => { const d = new Y.Doc() const aw = new Awareness(d) aw.setLocalState({ user: { name: localStorage.getItem("userName") || "Anonymous", color: localStorage.getItem("userColor") || "#d0bcff", }, }) return { doc: d, awareness: aw } }) // 2. Provider: created in useEffect with connect:false. // Listeners are attached BEFORE connect() so events are never missed. const [provider, setProvider] = useState<YjsProvider | null>(null) const [synced, setSynced] = useState(false) useEffect(() => { // Re-set awareness if React strict mode cleanup cleared it if (awareness.getLocalState() === null) { awareness.setLocalState({ user: { name: localStorage.getItem("userName") || "Anonymous", color: localStorage.getItem("userColor") || "#d0bcff", }, }) } const p = new YjsProvider({ doc, baseUrl: "https://your-server.com/v1/yjs/my-service", docId, awareness, connect: false, // listeners first, then connect }) // Attach listeners BEFORE connect() p.on("synced", (s: boolean) => { if (s) setSynced(true) }) p.on("error", (err: Error) => { console.error("[YjsProvider] error:", err) }) setProvider(p) p.connect() return () => { p.destroy() setProvider(null) } }, [doc, awareness, docId]) // 3. Clean up doc + awareness on component unmount useEffect(() => { return () => { awareness.destroy() doc.destroy() } }, [doc, awareness]) // 4. Editor setup goes here (see TipTap / CodeMirror sections below) // ... } ``` ### Why `connect: false` is required The provider starts its async connection flow immediately in the constructor when `connect` is `true` (the default). This means: - `ensureDocument` (PUT), `discoverSnapshot` (GET with 307 handling), and `startUpdatesStream` all fire before React's `useEffect` runs - The `synced` event can fire before any listener is attached - React strict mode double-renders make this race worse — the first render's provider is destroyed, and the event is lost With `connect: false`, the provider is inert until `p.connect()` is called explicitly — after all listeners are attached. No race, no missed events. ### Why doc/awareness are in `useState` but provider is in `useEffect` | | Doc + Awareness | Provider | | ------------------------ | --------------------------- | ------------------------------- | | Created via | `useState(() => ...)` | `useEffect` + `connect:false` | | Stable across re-renders | Yes (useState is stable) | Recreated when docId changes | | Event listeners | None needed before creation | Must be attached before connect | | Cleanup | Separate unmount effect | Effect cleanup destroys it | ### Why not `useMemo` `useMemo` is a caching hint, not a lifecycle primitive. React can evict and recreate the value without cleanup. `Y.Doc` and `Awareness` need explicit `.destroy()`. `useState` lazy init + `useEffect` cleanup is the correct primitive for objects with construction + destruction. ### Multi-document navigation When navigating between documents, key the component on `docId` so React fully unmounts and remounts it: ```tsx function DocPage() { const { docId } = Route.useParams() return <CollabEditor key={docId} docId={docId} /> } ``` Do NOT reuse ydoc/provider across documents — CRDTs are per-document. ### SSR requirement Routes using YjsProvider MUST disable SSR. The provider uses `fetch` and `EventSource` which don't exist server-side. ```tsx // TanStack Router export const Route = createFileRoute("/doc/$docId")({ ssr: false, component: DocPage, }) ``` ### Sharing doc/awareness via Context (multi-consumer apps) When several sibling components need the same doc and awareness (an editor, a presence list, a save button), wrap them in a Context Provider instead of prop-drilling. The Provider owns the lifecycle; children consume via a hook. ```tsx import { createContext, useContext, useEffect, useRef, useState } from "react" import type { ReactNode } from "react" import * as Y from "yjs" import { Awareness } from "y-protocols/awareness" import { YjsProvider } from "@durable-streams/y-durable-streams" import type { YjsProviderStatus } from "@durable-streams/y-durable-streams" interface YjsRoomContextValue { doc: Y.Doc awareness: Awareness roomId: string isLoading: boolean isSynced: boolean error: Error | null setUsername: (name: string) => void username: string } const YjsRoomContext = createContext<YjsRoomContextValue | null>(null) export function useYjsRoom(): YjsRoomContextValue { const ctx = useContext(YjsRoomContext) if (!ctx) throw new Error("useYjsRoom must be used inside YjsRoomProvider") return ctx } export function YjsRoomProvider({ roomId, baseUrl, initialUser, children, }: { roomId: string baseUrl: string initialUser: { name: string; color: string; colorLight: string } children: ReactNode }) { const [username, setUsernameState] = useState(initialUser.name) const usernameRef = useRef(username) usernameRef.current = username // Doc + awareness: stable across renders, with initial local state so the // first awareness broadcast already has the user info (no null-state flash). const [{ doc, awareness }] = useState(() => { const d = new Y.Doc() const a = new Awareness(d) a.setLocalState({ user: initialUser }) return { doc: d, awareness: a } }) // Destroy doc + awareness on unmount useEffect( () => () => { awareness.destroy() doc.destroy() }, [doc, awareness] ) const [isLoading, setIsLoading] = useState(true) const [isSynced, setIsSynced] = useState(false) const [error, setError] = useState<Error | null>(null) // Mutation path for username — merge into existing awareness state so // other fields (cursor, selection) aren't clobbered. const setUsername = (name: string) => { setUsernameState(name) const current = awareness.getLocalState() || {} awareness.setLocalState({ ...current, user: { ...initialUser, name }, }) } useEffect(() => { const provider = new YjsProvider({ doc, baseUrl, docId: roomId, awareness, connect: false, // attach listeners BEFORE connecting }) provider.on("synced", (s: boolean) => { setIsSynced(s) if (s) setIsLoading(false) }) provider.on("status", (s: YjsProviderStatus) => { if (s === "connected") setIsLoading(false) }) provider.on("error", (err: Error) => { setError(err) setIsLoading(false) }) // Strict Mode's effect cleanup may have wiped local state when the // previous provider was destroyed. Re-seed before connecting so the // first broadcast has user info (uses usernameRef, not the stale closure). if (awareness.getLocalState() === null) { awareness.setLocalState({ user: { ...initialUser, name: usernameRef.current }, }) } provider.connect() return () => provider.destroy() }, [roomId, doc, awareness, baseUrl, initialUser]) return ( <YjsRoomContext.Provider value={{ doc, awareness, roomId, isLoading, isSynced, error, setUsername, username, }} > {children} </YjsRoomContext.Provider> ) } ``` Usage — key the Provider on roomId so navigating between rooms fully tears down and rebuilds the CRDT: ```tsx <YjsRoomProvider key={roomId} roomId={roomId} baseUrl={baseUrl} initialUser={user} > <Editor /> {/* consumes via useYjsRoom() */} <PresenceList /> <SaveButton /> </YjsRoomProvider> ``` Three things to notice: (1) `status` + `synced` + `error` events are all attached before `connect()`, (2) the `usernameRef` is read at connect time to survive Strict Mode's double-invocation cleanup, (3) `setUsername` merges into existing local state instead of overwriting it. ## TipTap v3 ### Install ```bash npm install @tiptap/react @tiptap/starter-kit \ @tiptap/extension-collaboration @tiptap/extension-collaboration-caret ``` **Do NOT install `@tiptap/extension-collaboration-cursor`** — it's a broken v3 stub that imports `y-prosemirror` (replaced by `@tiptap/y-tiptap` in v3). Crashes with `TypeError: Cannot read properties of undefined (reading 'doc')`. **Do NOT install `y-prosemirror`**TipTap v3 internalized it. Having both creates duplicate `ySyncPluginKey` singletons that crash the editor. ### Editor setup Using the shared lifecycle pattern above, add the editor. Note: provider starts as `null` and becomes non-null after the `useEffect` runs. Use a conditional spread for `CollaborationCaret` and `[provider]` as a dep so the editor recreates when the provider arrives: ```tsx import { useEditor, EditorContent } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import Collaboration from "@tiptap/extension-collaboration" import CollaborationCaret from "@tiptap/extension-collaboration-caret" // Inside CollabEditor component, after the shared lifecycle code: const editor = useEditor( { extensions: [ StarterKit.configure({ undoRedo: false }), Collaboration.configure({ document: doc }), ...(provider ? [ CollaborationCaret.configure({ provider, user: { name: localStorage.getItem("userName") || "Anonymous", color: localStorage.getItem("userColor") || "#d0bcff", }, }), ] : []), ], editorProps: { attributes: { class: "prose max-w-none min-h-[60vh] focus:outline-none", }, }, }, [provider] // recreate editor when provider becomes available ) if (!synced) return <p>Connecting...</p> return <EditorContent editor={editor} /> ``` Key points: - `undoRedo: false` — Yjs has its own undo manager; StarterKit's conflicts - `CollaborationCaret` uses a conditional spread because `provider` is `null` on first render (before the effect). The `[provider]` dep array on `useEditor` recreates the editor when the provider arrives. - The `document` option takes the `Y.Doc` directly — TipTap creates the `Y.XmlFragment` internally ### Required CSS for collaboration carets **The CollaborationCaret extension does not include default styles.** Without the CSS below, carets render as unstyled inline elements that occupy the full line instead of appearing as thin cursor indicators. Add this to your global stylesheet: ```css .collaboration-carets__caret { border-left: 1px solid; border-right: 1px solid; margin-left: -1px; margin-right: -1px; pointer-events: none; position: relative; word-break: normal; } .collaboration-carets__label { border-radius: 3px 3px 3px 0; color: #0d0d0d; font-size: 12px; font-style: normal; font-weight: 600; left: -1px; line-height: normal; padding: 0.1rem 0.3rem; position: absolute; top: -1.4em; user-select: none; white-space: nowrap; } ``` The class names are `collaboration-carets__caret` and `collaboration-carets__label` (plural **carets**, not "cursor"). The border and background colors are set inline by the extension's default `render` function using each user's `color` field — the CSS above only handles positioning and sizing. For dark themes, change the label `color` to match your foreground (e.g. `color: #1b1b1f` for dark-on-light labels). See: https://tiptap.dev/docs/editor/extensions/functionality/collaboration-cursor ## CodeMirror 6 ### Install ```bash npm install codemirror @codemirror/state @codemirror/view y-codemirror.next ``` ### Editor setup Using the shared lifecycle pattern, add CodeMirror via a ref: ```tsx import { EditorView, basicSetup } from "codemirror" import { EditorState } from "@codemirror/state" import { yCollab } from "y-codemirror.next" // Inside CollabEditor component, after the shared lifecycle code: const editorRef = useRef<HTMLDivElement>(null) useEffect(() => { if (!editorRef.current || !synced) return const ytext = doc.getText("content") const state = EditorState.create({ doc: ytext.toString(), extensions: [ basicSetup, EditorView.lineWrapping, yCollab(ytext, awareness), ], }) const view = new EditorView({ state, parent: editorRef.current }) return () => view.destroy() }, [synced, doc, awareness]) if (!synced) return <p>Connecting...</p> return <div ref={editorRef} /> ``` Key points: - `yCollab(ytext, awareness)` handles both document sync and cursor rendering - Uses `Y.Text` (not `Y.XmlFragment` like TipTap) - Editor is created after `synced` to avoid rendering stale empty state ## Other editors **BlockNote** — built on TipTap. Use the same packages and pattern as TipTap above. BlockNote's `useCreateBlockNote` accepts a `collaboration` option with `provider` and `fragment` fields. **Lexical** — use `@lexical/yjs` with `CollaborationPlugin`. Pass the `YjsProvider` as the provider. Requires `ssr: false` like all Yjs editors. ## Common Mistakes ### CRITICAL Installing `@tiptap/extension-collaboration-cursor` (TipTap) Wrong: ```bash npm install @tiptap/extension-collaboration-cursor ``` Correct: ```bash npm install @tiptap/extension-collaboration-caret ``` The `-cursor` package is a broken v3 stub. It imports from `y-prosemirror` which uses a different `ySyncPluginKey` singleton than TipTap v3's internal `@tiptap/y-tiptap`. Crashes with `TypeError: Cannot read properties of undefined (reading 'doc')`. Source: TipTap v3 migration, @tiptap/extension-collaboration-caret package ### CRITICAL Auto-connecting provider without listeners Wrong: ```tsx // Provider auto-connects in constructor — synced event fires before // useEffect attaches the listener → stuck on "Connecting..." forever const [provider] = useState( () => new YjsProvider({ doc, baseUrl, docId, awareness }) ) useEffect(() => { provider.on("synced", (s) => { if (s) setSynced(true) }) // TOO LATE — synced already fired during construction }, [provider]) ``` Correct: Use the `useEffect` + `connect: false` pattern from the lifecycle section above. Listeners are attached before `connect()` is called. This is the #1 cause of "stuck Connecting" in agent-built apps. The provider connects, syncs, emits `synced: true`, but no listener is attached yet. React's `useEffect` runs after the render cycle, by which time the async connection has already completed. ### HIGH Using `useMemo` for Y.Doc or Awareness (all editors) Wrong: ```tsx const ydoc = useMemo(() => new Y.Doc(), []) const awareness = useMemo(() => new Awareness(ydoc), [ydoc]) ``` Correct: Use `useState(() => ...)` lazy initializers. `useMemo` is a caching hint. React can evict and recreate the value without calling cleanup. Leaked `Y.Doc` and `Awareness` instances accumulate listeners and connections. ### HIGH Not disabling SSR (all editors) Wrong: Using YjsProvider in a server-rendered route. Correct: Set `ssr: false` on the route. YjsProvider uses `fetch`/`EventSource` which don't exist server-side. ### MEDIUM Not keying component on docId for multi-document navigation Wrong: ```tsx <CollabEditor docId={docId} /> ``` Correct: ```tsx <CollabEditor key={docId} docId={docId} /> ``` Without `key`, React reuses the component. The old ydoc/provider persist with stale document data. Keying forces full unmount remount with fresh Yjs objects. ## See also - [yjs-getting-started](../yjs-getting-started/SKILL.md) — Install and server setup - [yjs-sync](../yjs-sync/SKILL.md) — Provider options, events, error recovery - [yjs-server](../yjs-server/SKILL.md) — Production deployment