Duplicated Content Issue in Collaborative Editing with Yjs

144 views Asked by At

Description:

I am implementing collaborative editing functionality using Yjs for a script editor in my React application. The collaborative editing works well, but I'm encountering an issue when multiple users type at the same time. For each key press, the entire typed content inside the Yjs document gets duplicated.

Here's a simplified overview of my setup:

Socket Handling (socket.mjs):

// socket.mjs
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { Server as SocketIOServer } from 'socket.io';
import * as Y from 'yjs';
import dotenv from 'dotenv';

const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development';
dotenv.config({ path: envFile });

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

const ydoc = new Y.Doc();
const yxmlFragment = ydoc.getXmlFragment('shared');

const ydocMap = new Map();

const activeUsers = {};

app.prepare().then(() => {
  const server = createServer((req, res) => {
    const parsedUrl = parse(req.url, true);
    handle(req, res, parsedUrl);
  });

  const io = new SocketIOServer(server, {
    cors: {
      origin: process.env.BASE_URL || "http://localhost:3000", // Use environment variable or default
      methods: ["GET", "POST"],
      allowedHeaders: ["*"],
      credentials: true
    }
  });

  io.on('connection', (socket) => {
    // When a user connects, send the current document state as a binary update
    console.log('Initiating')
    socket.emit('init', Y.encodeStateAsUpdate(ydoc));
    socket.on('updateScene', ({ sceneId, userId, update }) => {
        console.log(sceneId);
        const ydocForScene = ydocMap.get(sceneId);
        Y.applyUpdate(ydocForScene, update);

        socket.to(sceneId).emit(`update:${sceneId}`, { update: update, userId: userId });
    });
  });

  const PORT = process.env.SOCKET_PORT || 4000;
  server.listen(PORT, () => {
    console.log(`> Ready on http://localhost:${PORT}`);
  });
});

Script Editor Component (ScriptEditor.tsx):

// ScriptEditor.tsx
import * as yup from 'yup'
import io from 'socket.io-client';
import * as Y from 'yjs';

const ScriptEditor = (props: any) => {
  // Initialize Yjs document and WebsocketProvider
  const ydoc = new Y.Doc();
  const wsProvider = new WebsocketProvider('ws://localhost:3001', 'my-room', ydoc);
  initializeSocket(ydoc);

useEffect(() => {
    // Initialize once and store in refs
    if (!ydocRef.current) {
      ydocRef.current = new Y.Doc();
    }
    if (!yxmlFragmentRef.current) {
      yxmlFragmentRef.current = ydocRef.current.getXmlFragment('shared');
    }
    if (!socketRef.current) {
      socketRef.current = io(process.env.WEB_SOCKET_URL);
      socketRef.current.on('init', (update) => {
        if (update) {
          const updatedArray = new Uint8Array(update);
          Y.applyUpdate(ydocRef.current, updatedArray);
        }
      });
    }
     
    const ydoc = ydocRef.current;
    const yxmlFragment = yxmlFragmentRef.current;
    const socket = socketRef.current;

  socket.on(`update:${sceneId}`, (data) => {
    if (data.userId != userData.user.id) {
      try {
        // Convert the received ArrayBuffer to a Uint8Array
        const update = new Uint8Array(data.update);
        // Apply the update to the new document
        Y.applyUpdate(ydoc, update);
       } catch (e) {
          console.error('Error applying update:', e);
       }
    }
  });
}
  return (
    <div>
      {/* Your script editor UI and contenteditable area */}
      <div
            id="script-container"
            ref={editorRef}
            style={{ outline: 'none' }}
            contentEditable={!(project?.isDemoProject || !allowEdit)}
            dangerouslySetInnerHTML={{ __html: scriptText }}
            onInput={valueChange}
            onMouseUp={handleSelection}
            onKeyDown={handleKeyDown}
            onPaste={handlePaste}
          >
    </div>
  );
};

export default ScriptEditor;

Issue:

Whenever multiple users are simultaneously typing in the collaborative script editor, each key press results in the entire content getting duplicated in the Yjs document. This leads to unexpected behavior and makes the collaborative editing feature impractical.

Question:

  1. What could be causing this duplication issue when multiple users are typing simultaneously in a Yjs collaborative editing setup?
  2. How can I ensure that only the changes made by the user are reflected in the Yjs document without unnecessary duplications?

Additionally, I want to mention that we are using a custom contenteditable div for script editing, and we are not using any built-in plugins like Quill. Any insights, suggestions, or code improvements would be greatly appreciated. Thank you!

1

There are 1 answers

0
lefalaf On

I would need to see your input handling code (e.g. valueChange). But I would guess that you are updating yXmlFragment by overwriting its state completely. Yjs interprets that as deleting all existing text and inserting all new text; if two users do so concurrenty, they both insert the complete new text, duplicating it.

Instead, you need to figure out the precise changes (e.g. char 'x' inserted at ...) and call corresponding Yjs methods. E.g. see y-prosemirror's updateYText method, which takes a diff and sends the precise changes to a Y.Text node within the XML tree: https://github.com/yjs/y-prosemirror/blob/master/src/plugins/sync-plugin.js