Repository navigation
YjsPlugin prevents editor from rendering with Hocuspocus #4998
Replies: 2 comments 1 reply
|
Hi @Sammaiah-Guguloth! I'm Dosu and I'm helping the udecode team. A few things to check based on the collaboration demo and docs:
React.useEffect(() => {
editor.getApi(YjsPlugin).yjs.init({
id: documentId,
value: [{ type: 'p', children: [{ text: '' }] }],
});
return () => {
editor.getApi(YjsPlugin).yjs.destroy();
};
}, [editor]);
YjsPlugin.configure({
options: {
providers: [
{
type: 'hocuspocus',
options: {
name: documentId,
url: 'ws://localhost:8080',
},
},
],
},
})
The full working example is in To reply, just mention @dosu. Docs are dead. Just use Dosu. |
|
@dosu Thanks for the suggestions. I believe our setup already follows the recommended initialization pattern:
Client setup (simplified): import { Type, PaintBucketIcon } from 'lucide-react';
import { Plate, usePlateEditor, useSelectionFragmentProp } from 'platejs/react';
import { BasicMarksKit } from './plugins/basic-marks-kit';
import { BasicBlocksKit } from './plugins/basic-blocks-kit';
import { TableKit } from './plugins/table-kit';
import { DndKit } from './plugins/dnd-kit';
import { CommentKit } from './plugins/comment-kit';
import { DiscussionKit } from './plugins/discussion-kit';
import { SuggestionKit } from './plugins/suggestion-kit';
import { SlashKit } from './plugins/slash-kit';
import { KEYS } from 'platejs';
import type { TElement } from 'platejs';
import { getBlockType } from '@/components/editor/transforms';
import { FixedToolbar } from '@/components/ui/fixed-toolbar';
import { MarkToolbarButton } from '@/components/ui/mark-toolbar-button';
import { ToolbarButton } from '@/components/ui/toolbar';
import { Editor, EditorContainer } from '@/components/ui/editor';
import { MediaToolbarButton } from '@/components/ui/media-toolbar-button';
import { CommentToolbarButton } from '@/components/ui/comment-toolbar-button';
import { ListKit } from './plugins/list-kit';
import { MediaKit } from './plugins/media-kit';
import { ToggleKit } from './plugins/toggle-kit';
import { CodeBlockKit } from './plugins/code-block-kit';
import { CalloutKit } from './plugins/callout-kit';
import { EmojiKit } from './plugins/emoji-kit';
import { TocKit } from './plugins/toc-kit';
import { ColumnKit } from './plugins/column-kit';
import { MathKit } from './plugins/math-kit';
import { ExcalidrawKit } from './plugins/excalidraw-kit';
import { DateKit } from './plugins/date-kit';
import { MarkdownKit } from './plugins/markdown-kit';
import { CodeDrawingKit } from './plugins/code-drawing-kit';
import { FootnoteKit } from './plugins/footnote-kit';
import { FontKit } from './plugins/font-kit';
import { BlockSelectionPlugin } from '@platejs/selection/react';
import { FontSizeToolbarButton } from '../ui/font-size-toolbar-button';
import { FontColorToolbarButton } from '../ui/font-color-toolbar-button';
import { IndentKit } from './plugins/indent-kit';
import { useAutoSave } from '@/hooks/useAutoSave';
import { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '@/redux/store';
import { discussionPlugin } from './plugins/discussion-kit';
import { ExportToolbarButton } from '../ui/export-toolbar-button';
import { AIKit } from './plugins/ai-kit';
import { CopilotKit } from './plugins/copilot-kit';
import { useChat } from './use-chat';
import { FloatingToolbar } from '@/components/ui/floating-toolbar';
import { AIToolbarButton } from '@/components/ui/ai-toolbar-button';
import { Wand2 } from 'lucide-react';
import { YjsPlugin } from '@platejs/yjs/react';
import { CursorOverlay } from '@/components/ui/cursor-overlay';
import { useMounted } from '@/hooks/use-mounted';
function getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
// Only responsible for connecting useChat to the plugin store.
// useEditorChat lives inside AIMenu and handles open/close anchoring.
function EditorChatController({ editor }: { editor: any }) {
useChat(editor);
return null;
}
function BlockToolbarButton({
nodeType,
children,
...props
}: React.ComponentProps<typeof ToolbarButton> & { nodeType: string }) {
const activeBlockType = useSelectionFragmentProp({
defaultValue: KEYS.p,
getProp: (node) => getBlockType(node as TElement),
});
const isActive = activeBlockType === nodeType;
return (
<ToolbarButton pressed={isActive} {...props}>
{children}
</ToolbarButton>
);
}
interface PlateEditorProps {
initialValue: any[];
documentId: string;
authToken?: string;
}
export function PlateEditor( {initialValue , documentId, authToken} : PlateEditorProps ) {
const { user } = useSelector((state: RootState) => state.auth);
const [userColor] = useState(() => getRandomColor());
const mounted = useMounted();
// Default content shown when nothing is in localStorage
const DEFAULT_VALUE = [{ type: 'p', children: [{ text: '' }] }];
const editor = usePlateEditor(
{
skipInitialization: true,
plugins: [
YjsPlugin.configure({
render: {
afterEditable: CursorOverlay,
},
options: {
onConnect: ({ type }) => console.log(`[DEBUG] Yjs Provider ${type} connected!`),
onDisconnect: ({ type }) => console.log(`[DEBUG] Yjs Provider ${type} disconnected.`),
onError: ({ type, error }) => console.error(`[DEBUG] Yjs Provider ${type} error:`, error),
cursors: {
data: {
name: user?.name || 'Anonymous',
color: userColor,
},
},
providers: [
{
type: 'hocuspocus',
options: {
name: documentId,
url: process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'ws://0.0.0.0:8888',
token: authToken,
},
},
],
},
}),
BlockSelectionPlugin,
...AIKit,
...CopilotKit,
...BasicMarksKit,
...BasicBlocksKit,
...TableKit,
...DndKit,
...MediaKit,
...CommentKit,
...DiscussionKit,
...SuggestionKit,
...SlashKit,
...ToggleKit,
...CodeBlockKit,
...CalloutKit,
...EmojiKit,
...TocKit,
...ColumnKit,
...MathKit,
...ExcalidrawKit,
...DateKit,
...MarkdownKit,
...CodeDrawingKit,
...FootnoteKit,
...FontKit,
...IndentKit,
...ListKit,
],
},
[documentId]
);
// Bind dynamic user to discussion options on mount / user change
useEffect(() => {
if (user && editor) {
editor.setOption(discussionPlugin, 'currentUserId', user.id);
const currentUsers = editor.getOption(discussionPlugin, 'users') || {};
editor.setOption(discussionPlugin, 'users', {
...currentUsers,
[user.id]: {
id: user.id,
name: user.name,
avatarUrl: `https://api.dicebear.com/9.x/glass/svg?seed=${user.name}`,
},
});
}
}, [editor, user]);
// Initialize Yjs connection when ready
useEffect(() => {
if (!mounted || !editor || !authToken) return;
console.log('[DEBUG] Calling yjs.init with documentId:', documentId);
editor.getApi(YjsPlugin).yjs.init({
id: documentId,
value: DEFAULT_VALUE,
autoSelect: 'end',
}).catch(err => console.error('[DEBUG] yjs.init failed:', err));
return () => {
try {
editor.getApi(YjsPlugin).yjs.destroy();
} catch (err) {
console.warn('[DEBUG] yjs.destroy warning:', err);
}
};
}, [editor, mounted, documentId, authToken]);
return (
<Plate editor={editor}>
<EditorChatController editor={editor} />
{/*Toolbar */}
<FixedToolbar className="
sticky top-0 z-50
flex justify-between w-full overflow-x-auto whitespace-nowrap
gap-1 px-2 py-1
bg-background/95 backdrop-blur-sm
border-b border-border
rounded-t-xl
scrollbar-hide
">
{/* Block types */}
<div className='flex flex-row '>
<BlockToolbarButton nodeType="h1" onClick={() => editor.tf.h1.toggle()}>H1</BlockToolbarButton>
<BlockToolbarButton nodeType="h2" onClick={() => editor.tf.h2.toggle()}>H2</BlockToolbarButton>
<BlockToolbarButton nodeType="h3" onClick={() => editor.tf.h3.toggle()}>H3</BlockToolbarButton>
<BlockToolbarButton nodeType={KEYS.blockquote} onClick={() => editor.tf.blockquote.toggle()}>
Quote
</BlockToolbarButton>
</div>
{/* Marks */}
<div className='flex flex-row'>
<MarkToolbarButton nodeType="bold" tooltip="Bold (⌘+B)">
<b>B</b>
</MarkToolbarButton>
<MarkToolbarButton nodeType="italic" tooltip="Italic (⌘+I)">
<i>I</i>
</MarkToolbarButton>
<MarkToolbarButton nodeType="underline" tooltip="Underline (⌘+U)">
<u>U</u>
</MarkToolbarButton>
<MarkToolbarButton nodeType="strikethrough" tooltip="Strikethrough">
<s>S</s>
</MarkToolbarButton>
</div>
{/* Font */}
<div className='flex flex-row'>
<FontSizeToolbarButton />
<FontColorToolbarButton nodeType={KEYS.color} tooltip="Font Color">
<Type className="size-4" />
</FontColorToolbarButton>
<FontColorToolbarButton
nodeType={KEYS.backgroundColor}
tooltip="Background Color"
>
<PaintBucketIcon className="size-4" />
</FontColorToolbarButton>
</div>
{/* Table */}
<div className='flex flex-row'>
<ToolbarButton
onClick={() => editor.tf.insert.table({ rowCount: 3, colCount: 3 })}
tooltip="Insert Table"
>
Table
</ToolbarButton>
</div>
{/* Media */}
<div className='flex flex-row'>
<MediaToolbarButton nodeType={KEYS.img} />
<MediaToolbarButton nodeType={KEYS.video} />
<MediaToolbarButton nodeType={KEYS.audio} />
<MediaToolbarButton nodeType={KEYS.file} />
</div>
{/* Comments */}
<CommentToolbarButton />
{ /* Export*/}
<ExportToolbarButton />
</FixedToolbar>
<EditorContainer>
<Editor placeholder="Start writing... (type / for commands)" />
{/* Floating toolbar — appears on text selection with AI shortcut */}
<FloatingToolbar>
<AIToolbarButton tooltip="AI commands">
<Wand2 className="size-4" />
Ask AI
</AIToolbarButton>
<MarkToolbarButton nodeType="bold" tooltip="Bold (⌘+B)">
<b>B</b>
</MarkToolbarButton>
<MarkToolbarButton nodeType="italic" tooltip="Italic (⌘+I)">
<i>I</i>
</MarkToolbarButton>
<MarkToolbarButton nodeType="underline" tooltip="Underline (⌘+U)">
<u>U</u>
</MarkToolbarButton>
<MarkToolbarButton nodeType="strikethrough" tooltip="Strikethrough">
<s>S</s>
</MarkToolbarButton>
</FloatingToolbar>
</EditorContainer>
</Plate>
);
}Server setup: import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import jwt from 'jsonwebtoken';
import { DocumentModel } from '../document/document.model';
export const hocuspocusServer = new Server({
name: 'code-edi-collab',
port: 8888,
debounce: 2000,
maxDebounce: 10000,
async onAuthenticate({ token, documentName }: any) {
console.log("onAuthenticate of hocuspocus :", { hasToken: !!token, documentName });
if (!token) {
console.error('No authentication token provided');
throw new Error('No authentication token provided');
}
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET as string) as { userId: string };
// Verify document access
const doc = await DocumentModel.findById(documentName).select('owner collaborators');
if (!doc) {
throw new Error('Document not found');
}
const userId = decoded.userId;
// console.log("userId : " , userId); fine working good
const isOwner = doc.owner.toString() === userId;
const isCollaborator = doc.collaborators.some((collabId) => collabId.toString() === userId);
if (!isOwner && !isCollaborator) {
throw new Error('Forbidden: No access to this document');
}
return { userId };
} catch (error) {
throw new Error('Authentication failed');
}
},
async onConnect(data: any) {
console.log(`New connection to ${data.documentName}, awaiting authentication...`);
},
async onDisconnect(data: any) {
console.log(`User ${data.context.userId} disconnected from ${data.documentName}`);
},
async onChange(data: any) {
console.log("Change detected in document:", data.documentName);
},
extensions: [
new Database({
// Load saved state → Hocuspocus sends it to all clients
fetch: async ({ documentName }) => {
const doc = await DocumentModel.findById(documentName).select('yjsState');
if (!doc) return null;
// If we have saved Yjs binary state, return it
if (doc.yjsState) {
return new Uint8Array((doc.yjsState as any).buffer, (doc.yjsState as any).byteOffset, (doc.yjsState as any).byteLength);
}
return null;
},
// Hocuspocus calls this automatically on changes
store: async ({ documentName, state }) => {
await DocumentModel.findByIdAndUpdate(documentName, {
yjsState: Buffer.from(state),
});
},
}),
],
});
// Start the standalone Hocuspocus server
hocuspocusServer.listen().then(() => {
console.log("Hocuspocus WebSocket server is running ");
});The WebSocket connection is established successfully, authentication passes, and document access checks succeed. The issue is specifically that enabling Does anything stand out as incorrect in this setup, particularly around the database persistence or Yjs initialization flow? |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
YjsPlugin Causes Editor Not to Render with Hocuspocus Integration
Description
I'm trying to add real-time collaboration to a Plate.js editor using the built-in Yjs plugin and a Hocuspocus server.
The editor works correctly without the Yjs plugin. However, as soon as I enable Yjs collaboration, the editor fails to render properly. No collaboration features become available, and the editor UI either remains blank or does not initialize as expected.
Environment
Client Setup
Initialization:
Hocuspocus Server Setup
What I've Verified
Expected Behavior
The editor should render normally and synchronize document state through Yjs/Hocuspocus.
Actual Behavior
When YjsPlugin is enabled, the editor does not render. Disabling the plugin immediately restores normal editor behavior.
Any guidance would be greatly appreciated.
All reactions