React-codemirror with yjs + y-websocket last line does not sync properly

Hi Y.js community,

There’s a weird quirk when typing on the last line and only the last 2 characters are synced. Anything typed after that will not be synced. I’m not 100% sure what the cause is

See the video below for a demo of the issue I’m having.

Tech Stack being used:

frontend: React + codemirror + y.js + y-websocket provider (with TypeScript if that matters)
backend: Node.js + custom y-websocket setup that follows the suggestion by @dmonad in this comment (with TypeScript too)

My intention is to use the server to provide the initial value to codemirror. The code that is used is in the following format below and can’t be changed unfortunately:

[
        "# Write your code below:\n",
        'something = "2"\n',
        "something2 = 5\n",
        "print(something + something2)\n",
        "\n",
        "list = []\n",
        "for num in range(1,11):\n",
        "    list.append(num)\n",
        "\n",
        "print(list)",
        "\n",
    ],

The idea is to join() the array of lines of code together and then apply it to the doc

Codemirror/frontend component below

import React, { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { CodemirrorBinding } from "y-codemirror";
import { Controlled as CodeMirror } from "react-codemirror2";
import "codemirror/mode/python/python";
import "codemirror/lib/codemirror.css";

interface CodeMirrorEditorProps {
  code: string;
  cellId: string;
  userId: string;
  userName: string;
  cursorColor: string;
}

const CodeMirrorEditor = (props: CodeMirrorEditorProps) => {
  const [codeMirrorText, setCodeMirrorText] = useState("");
  const [hasLineNumber, setHasLineNumber] = useState(true);
  const codeMirrorRef = useRef<undefined | CodeMirror.Editor>();

  useEffect(() => {
    if (!codeMirrorRef.current) return;

    // A Yjs document holds the shared data
    const ydoc = new Y.Doc({
      meta: {
        cellId: props.cellId,
      },
    });

    const wsProvider = new WebsocketProvider(
      "ws://localhost:1234",
      props.cellId,
      ydoc
    );
    // Define a shared text type on the document
    const yText = ydoc.getText(`codemirror`);

    wsProvider.awareness.setLocalStateField("user", {
      name: props.userName,
      color: props.cursorColor,
    });
    // "Bind" the codemirror editor to a Yjs text type.
    const _codemirrorBinding = new CodemirrorBinding(
      yText,
      codeMirrorRef.current,
      wsProvider.awareness
    );

    wsProvider.on("status", (event: { status: string }) => {
      console.log(event.status); // logs "connected" or "disconnected"
    });

  }, [props.cellId, props.cursorColor, props.userName]);

  const handleChange = (value: string) => {
    console.log("inside handle change: ", value);
    setCodeMirrorText(value);
  };

  return (
    <div style={{ marginTop: "48px" }}>
      <div>Code Mirror Editor</div>
      <button
        type="button"
        onClick={() => setHasLineNumber((prevState) => !prevState)}
      >
        Toggle line number
      </button>
      <CodeMirror
        editorDidMount={(editor) => (codeMirrorRef.current = editor)}
        value={codeMirrorText}
        onBeforeChange={(_editor, _data, value) => {
          handleChange(value);
        }}
        options={{
          lineNumbers: hasLineNumber,
          mode: "python",
          extraKeys: {
            "Ctrl-Space": (cm) => {
              const { line, ch } = cm.getCursor();
              const lineOfCode = cm.getLine(line);
              // do some stuff when user hits Ctrl-Space
            },
          },
        }}
      />
      
    </div>
  );
};

export default CodeMirrorEditor;

Server side

import { Server as WsServer } from "ws";
import { createServer as createHttpServer } from "http";
import { setupWSConnection, setPersistence } from "y-websocket/bin/utils";
import * as Y from "yjs";

const cells = [
    {
      metadata: {
        id: "1",
      },
      source: [
        "# Write your code below:\n",
        'something = "2"\n',
        "something2 = 5\n",
        "print(something + something2)\n",
        "\n",
        "list = []\n",
        "for num in range(1,11):\n",
        "    list.append(num)\n",
        "\n",
        "print(list)",
        "\n",
      ],
    },
    {
      metadata: {
        id: "2",
      },
      source: [
        "data = [73284, 8784.3, 9480938.2, 984958.3, 24131, 45789, 734987, 23545.3, 894859.2, 842758.3]\n",
        "\n",
        "# Write your code below:\n",
        "maximum = data[0]\n",
        "\n",
        "for num in data:\n",
        "    if(num > maximum):\n",
        "        maximum = num\n",
        "\n",
        "print(maximum)",
        "\n",
      ],
    },
  ]

const port = process.env.PORT || 1234;
const wss = new WsServer({ noServer: true });

const server = createHttpServer((_request, response) => {
  response.writeHead(200, { "Content-Type": "text/plain" });
  response.end("okay");
});

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

setPersistence({
  bindState: async (documentName: string, doc: any) => {
    
    await delay(1000); // some random delay to signify retrieving data from db
    
    //documentName refers to the id of the cell in the db which the frontend will have
    const foundCell = cells.find(
      (cell) => cell.metadata.id === documentName
    );

    const foundSourceCode = foundCell?.source.join("") as string;

    const yText = doc.getText("codemirror");
    yText.insert(0, foundSourceCode);
    const ecodedState = Y.encodeStateAsUpdate(doc);

    doc.on("update", (update) => {
      Y.applyUpdate(doc, update);
    });

    return Y.applyUpdate(doc, ecodedState);
    // Here you listen to granular document updates and store them in the database
    // You don't have to do this, but it ensures that you don't lose content when the server crashes
    // See https://github.com/yjs/yjs#Document-Updates for documentation on how to encode
    // document updates
  },
  writeState: (_identifier, _doc) => {
   
    // This is called when all connections to the document are closed.
    // In the future, this method might also be called in intervals or after a certain number of updates.
    return new Promise<void>((resolve) => {
      // When the returned Promise resolves, the document will be destroyed.
      // So make sure that the document really has been written to the database.
      resolve();
    });
  },
});

wss.on("connection", setupWSConnection);

server.on("upgrade", (request, socket, head) => {
  // You may check auth of request here..

  const handleAuth = (ws) => {
    wss.emit("connection", ws, request);
  };
  wss.handleUpgrade(request, socket, head, handleAuth);
});

server.listen(port, () => {
  console.log("running on port", port);
});

Hope to hear back soon! thanks!

Welcome to the discussion board @pakatahwa,

Unfortunately, this is too much code for me to debug. Can you reproduce the issue on pure CodeMirror without React and all the reactive data handling?

You probably shouldn’t reset the CodeMirror state on every transaction (setCodeMirrorText).

The effect handler doesn’t seem to delete old state. When useEffect is called multiple times, you will create multiple editor bindings. You should destroy the provider and the binding when you don’t reference them anymore (i.e. x.destroy()).

Thanks @dmonad!

I think to reproduce it on pure Codemirror would not be accurate as my team and I would be working on a React project at the end of the day.

The effect handler doesn’t seem to delete old state. When useEffect is called multiple times, you will create multiple editor bindings. You should destroy the provider and the binding when you don’t reference them anymore (i.e. x.destroy() ).

Noted on the above. A simple clean up function can be used. But the issue still persists.

useEffect(() => {

   // some other code here...
  const wsProvider = new WebsocketProvider(
      "ws://localhost:1234",
      props.cellId,
      ydoc
    );

  return () => {
      wsProvider.destroy();   // clean up/destroy when component unmounts
    };

  }, [props.cellId, props.cursorColor, props.userName]);

I’ll try to boil down the issue to something simpler.

If it helps, if I just used the default y-websocket PORT=1234 npx y-websocket-server with no default values or bindState on the server side, everything works fine.