BlockNote icon indicating copy to clipboard operation
BlockNote copied to clipboard

Undo / Redo are not working when collaboration is enabled

Open notABot101010 opened this issue 3 months ago • 17 comments

Describe the bug

Hello and thank you for the work on BlockNote!

It seems that undo / redo (Ctrl + Z / Ctrl + Y) are not working when the collaboration feature with Yjs is enabled.

When enabling collaboration with a minimal YJsProvider, Ctrl + Z does nothing. If we comment the collaboration part of the config it's working again.

Also, when trying to implement it by hand with the following code, it's still not working:

const undoHandler = useCallback((e: KeyboardEvent) => {
    if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
      console.log('undo'); // displayed
      editor.undo(); // <- NOT WORKING
    }
  }, []);

  useWindowEvent('keydown', undoHandler);

To Reproduce Here is a minimal reproduction in a sandbox: https://stackblitz.com/edit/github-qp3au5av?file=src%2FApp.tsx

And the code, in case the sandbox is not working:

App.jsx

import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import * as Y from 'yjs';

export default function App() {
  const ydoc = new Y.Doc();
  const yLoggingProvider = new YProvider(ydoc);
  const editor = useCreateBlockNote({
    collaboration: {
      provider: yLoggingProvider,
      fragment: ydoc.getXmlFragment('blocks'),
      user: {
        name: 'My Username',
        color: '#ff0000',
      },
      // showCursorLabels: 'always',
    },
  });

  // Renders the editor instance using a React component.
  return <BlockNoteView editor={editor} />;
}

export class YProvider {
  private doc: Y.Doc;
  private name: string;

  constructor(doc: Y.Doc, name = 'YProvider') {
    this.doc = doc;
    this.name = name;

    this.doc.on('update', this.onLocalUpdate);

    console.log(`[${this.name}] provider started`);
  }

  private onLocalUpdate = (update: Uint8Array, origin: any) => {
    Y.applyUpdate(this.doc, update);
  };

  /**
   * Apply a received update from an external source.
   * This bypasses the debounce and applies immediately.
   */
  public receiveUpdate(update: Uint8Array): void {
    Y.applyUpdate(this.doc, update, `${this.name}-external`);
  }

  public destroy(): void {
    this.doc.off('update', this.onLocalUpdate);
  }
}

Alternatively, you can try with the official WebrtcProvider as shown in the doc here: https://www.blocknotejs.org/docs/features/collaboration

const provider = new WebrtcProvider("my-document-id", ydoc);
  const editor = useCreateBlockNote({
    collaboration: {
      provider: provider,
      // Where to store BlockNote data in the Y.Doc:
      fragment: ydoc.getXmlFragment("blocks"),
      // Information (name and color) for this user:
      user: {
        name: "My Username",
        color: "#ff0000",
      },
    // showCursorLabels: 'always',
    }
  });

It's not working.

Misc

  • Node version: 25.2
  • Package manager: npm
  • Browser: It's not working with multiple browsers
  • [ ] I'm a sponsor and would appreciate if you could look into this sooner than later 💖

notABot101010 avatar Dec 09 '25 07:12 notABot101010

Hm, @notABot101010 I think what is happening here is that your set up for the ydoc provider creates a new instance each time and you are running the editor within a strict mode app.

This setup works for me.

import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { StrictMode } from "react";
import * as Y from "yjs";

const ydoc = new Y.Doc();

export class YProvider {
  private doc: Y.Doc;
  private name: string;

  constructor(doc: Y.Doc, name = "YProvider") {
    this.doc = doc;
    this.name = name;

    this.doc.on("update", this.onLocalUpdate);

    console.log(`[${this.name}] provider started`);
  }

  private onLocalUpdate = (update: Uint8Array, origin: any) => {
    Y.applyUpdate(this.doc, update);
  };

  /**
   * Apply a received update from an external source.
   * This bypasses the debounce and applies immediately.
   */
  public receiveUpdate(update: Uint8Array): void {
    Y.applyUpdate(this.doc, update, `${this.name}-external`);
  }

  public destroy(): void {
    this.doc.off("update", this.onLocalUpdate);
  }
}

const yLoggingProvider = new YProvider(ydoc);

function App() {
  const editor = useCreateBlockNote({
    collaboration: {
      provider: yLoggingProvider,
      // Where to store BlockNote data in the Y.Doc:
      fragment: ydoc.getXmlFragment("blocks"),
      // Information (name and color) for this user:
      user: {
        name: "My Username",
        color: "#ff0000",
      },
      // showCursorLabels: 'always',
    },
  });

  // Renders the editor instance using a React component.
  return <BlockNoteView editor={editor} />;
}

export default function A() {
  return (
    <StrictMode>
      <App />
    </StrictMode>
  );
}

I ran this on the latest version that is on main, so if it does not work for you, I am going to cut a release later today that you can try and it should work for you the same way that it works for me

nperez0111 avatar Dec 09 '25 14:12 nperez0111

Hey @nperez0111 , thank you for the quick answer!

I can confirm that the code your provided is not working with a fresh react-ts vitejs app and if we comment the collaboration configuration it's working.

  "dependencies": {
    "@blocknote/core": "^0.44.1",
    "@blocknote/mantine": "^0.44.1",
    "@blocknote/react": "^0.44.1",
    "@mantine/core": "^8.3.10",
    "@mantine/hooks": "^8.3.10",
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "yjs": "^13.6.27"
  },

main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";
import * as Y from "yjs";

import './index.css';
import '@mantine/core/styles.css';
import "@blocknote/mantine/blocknoteStyles.css";
import "@blocknote/core/fonts/inter.css";

const ydoc = new Y.Doc();

export class YProvider {
  private doc: Y.Doc;
  private name: string;

  constructor(doc: Y.Doc, name = "YProvider") {
    this.doc = doc;
    this.name = name;

    this.doc.on("update", this.onLocalUpdate);

    console.log(`[${this.name}] provider started`);
  }

  private onLocalUpdate = (update: Uint8Array, origin: any) => {
    Y.applyUpdate(this.doc, update);
  };

  public receiveUpdate(update: Uint8Array): void {
    Y.applyUpdate(this.doc, update, `${this.name}-external`);
  }

  public destroy(): void {
    this.doc.off("update", this.onLocalUpdate);
  }
}

const yLoggingProvider = new YProvider(ydoc);

function App() {
  const editor = useCreateBlockNote({
    collaboration: {
      provider: yLoggingProvider,
      fragment: ydoc.getXmlFragment("blocks"),
      user: {
        name: "My Username",
        color: "#ff0000",
      },
    },
  });

  return <BlockNoteView editor={editor} />;
}


createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <MantineProvider>
      <App />
    </MantineProvider>
  </StrictMode>,
)

index.css

html, body, #root, .bn-container, .bn-editor {
  height: 100%;
}

I will provide an update once the next version of BlockNote is released.

notABot101010 avatar Dec 09 '25 15:12 notABot101010

Sure, here is the latest version: https://github.com/TypeCellOS/BlockNote/releases/tag/v0.44.2

nperez0111 avatar Dec 09 '25 15:12 nperez0111

@nperez0111 Thank you!

Unfortunately, even with version 0.44.2, it's still not working with the exact code mentioned above, but if I comment the collaboration configuration it's working 🤔

Could you try to reproduce with a new from scratch project and using @blocknote packages from npm (npm create vite@latest blocknote-collab-test -- --template react-ts

I'm rather puzzled, so current hypothesis is that a recent update may have broken the feature and that your local environment is using older versions of the packages.

notABot101010 avatar Dec 09 '25 16:12 notABot101010

You are right, I'm also running into issues getting this to run in the codesandbox. When I removed your provider, it seems to work correctly though. So I guess it is something to do with that maybe?

I ran this, and it worked @notABot101010

import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { StrictMode } from 'react';
import * as Y from 'yjs';

const ydoc = new Y.Doc();


function App() {
  const editor = useCreateBlockNote({
    collaboration: {
      provider: {} as any,
      // Where to store BlockNote data in the Y.Doc:
      fragment: ydoc.getXmlFragment('blocks'),
      // Information (name and color) for this user:
      user: {
        name: 'My Username',
        color: '#ff0000',
      },
      // showCursorLabels: 'always',
    },
  });

  // Renders the editor instance using a React component.
  return <BlockNoteView editor={editor} />;
}

export default function A() {
  return (
    <StrictMode>
      <App />
    </StrictMode>
  );
}

nperez0111 avatar Dec 09 '25 16:12 nperez0111

@nperez0111 that's surprising because running this exact code in a sandbox (https://stackblitz.com/edit/github-zzgxsmeg?file=main.tsx) is not working.

Also, if we use the exact provider mentioned in the docs here: https://www.blocknotejs.org/docs/features/collaboration

const yProvider = new WebrtcProvider("my-document-id", ydoc);

It's not working with a blank project and the latest version of Blocknote.

I may have identified the source of the issue: https://github.com/TypeCellOS/BlockNote/blob/356a3ef7224fb0b4778a3b975ab84d5565344b62/packages/core/src/extensions/Collaboration/YUndo.ts#L7

I believe that the trackedOrigins should not be [editor].

Also this file has been updated just last week which may indicate why the breakage is recent.

Otherwise, the issue may come from the fact that the YUndo extension depends on the YCursor extension, but here

https://github.com/TypeCellOS/BlockNote/blob/356a3ef7224fb0b4778a3b975ab84d5565344b62/packages/core/src/editor/managers/ExtensionManager/extensions.ts#L194

The YCursor extension is enabled only if the collaboration provider has an awareness field.

Could we reopen the issue please? I believe that the bug is real.

You can clean this repo, install, build and run the examples/07-collaboration/01-partykit project to confirm that it's not working.

notABot101010 avatar Dec 10 '25 07:12 notABot101010

@nperez0111 I've found 2 solutions to this bug, I will let you chose which one is the most relevant, or if more work is needed.

From my initial testing, both are working, but I'm not sure which one is the cleanest.

Solution 1: Always enable HistoryExtension

in extensions.ts

  if (options.collaboration) {
    extensions.push(ForkYDocExtension(options.collaboration));
    if (options.collaboration.provider?.awareness) {
      extensions.push(YCursorExtension(options.collaboration));
    }
    extensions.push(YSyncExtension(options.collaboration));
    extensions.push(YUndoExtension(options.collaboration));
    extensions.push(SchemaMigration(options.collaboration));
  }

  extensions.push(HistoryExtension());

in YUndo.ts:

import { redoCommand, undoCommand, yUndoPlugin } from "y-prosemirror";
import { createExtension } from "../../editor/BlockNoteExtension.js";

// eslint-disable-next-line no-empty-pattern
export const YUndoExtension = createExtension(({}) => {
  return {
    key: "yUndo",
    prosemirrorPlugins: [yUndoPlugin()],
    // dependsOn: ["ySync"],
    undoCommand: undoCommand,
    redoCommand: redoCommand,
  } as const;
});

in StateManager.ts

  /**
   * Undo the last action.
   */
  public undo(): boolean {
    // Try Yjs undo first if the collaboration stack is present.
    const undoPlugin = this.editor.getExtension<typeof YUndoExtension>("yUndo");
    if (undoPlugin) {
      const result = this.exec(undoPlugin.undoCommand);
      if (result) {
        return true;
      }
      // If Yjs couldn’t undo (empty stack or not ready), fall back to history.
    }

    const historyPlugin =
      this.editor.getExtension<typeof HistoryExtension>("history");
    if (historyPlugin) {
      return this.exec(historyPlugin.undoCommand);
    }

    throw new Error("No undo plugin found");
  }

  /**
   * Redo the last action.
   */
  public redo(): boolean {
    const undoPlugin = this.editor.getExtension<typeof YUndoExtension>("yUndo");
    if (undoPlugin) {
      const result = this.exec(undoPlugin.redoCommand);
      if (result) {
        return true;
      }
      // Yjs redo not available; fall back to history.
    }

    const historyPlugin =
      this.editor.getExtension<typeof HistoryExtension>("history");
    if (historyPlugin) {
      return this.exec(historyPlugin.redoCommand);
    }

    throw new Error("No redo plugin found");
  }

Solution 2: Apply the Tiptap workaround for y-prosemirror issues #114 and #102

in YUndo.ts

import { redoCommand, undoCommand, yUndoPlugin, yUndoPluginKey } from "y-prosemirror";
import { createExtension } from "../../editor/BlockNoteExtension.js";

// eslint-disable-next-line no-empty-pattern
export const YUndoExtension = createExtension(({}) => {
  // Apply the Tiptap workaround for y-prosemirror issues #114 and #102
  // See: https://github.com/yjs/y-prosemirror/issues/114
  const yUndoPluginInstance = yUndoPlugin();
  const originalUndoPluginView = yUndoPluginInstance.spec.view;

  yUndoPluginInstance.spec.view = (view) => {
    const pluginState = yUndoPluginKey.getState(view.state);
    if (!pluginState) {
      return {};
    }
    const undoManager = pluginState.undoManager as any;

    if (undoManager.restore) {
      undoManager.restore();
      undoManager.restore = () => {
        // noop
      };
    }

    const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined;

    return {
      destroy: () => {
        const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager);
        const observers = undoManager._observers;

        undoManager.restore = () => {
          if (hasUndoManSelf) {
            undoManager.trackedOrigins.add(undoManager);
          }

          undoManager.doc.on('afterTransaction', undoManager.afterTransactionHandler);
          undoManager._observers = observers;
        };

        if (viewRet?.destroy) {
          viewRet.destroy();
        }
      },
    };
  };

  return {
    key: "yUndo",
    prosemirrorPlugins: [yUndoPluginInstance],
    undoCommand: undoCommand,
    redoCommand: redoCommand,
  } as const;
});

notABot101010 avatar Dec 10 '25 08:12 notABot101010

@notABot101010, you may be onto something with the awareness for the provider. I will take a look at that

nperez0111 avatar Dec 10 '25 15:12 nperez0111

We cannot enable the prosemirror-history extension at the same time as the y-undo plugin. So, I'm looking into options there. I don't understand why it is acting different in my dev environment

nperez0111 avatar Dec 10 '25 16:12 nperez0111

It seems that there is some really weird interaction between Yjs, prosemirror and the various plumbing layers.

May also be related to https://github.com/yjs/y-prosemirror/issues/114 and https://github.com/yjs/y-prosemirror/issues/102

I've spent a lot of time on this issue but I'm still really sure about where it can come from due to the many projects interacting together.

notABot101010 avatar Dec 10 '25 16:12 notABot101010

Also, I've tried to downgrade blocknote to version 0.42 but it was still not working.

Is there a way to get the exact version numbers of the dependencies used to build the website that is live at https://www.blocknotejs.org ?

Because undo / redo is working on the landing page with collaboration enabled, if an update (whether it be in Yjs or blocknote) broke the collaboration undo / redo, at least knowing the versions of when it was working may help.

notABot101010 avatar Dec 10 '25 16:12 notABot101010

Is there a way to get the exact version numbers of the dependencies used to build the website that is live at block

https://github.com/TypeCellOS/BlockNote/blob/356a3ef7224fb0b4778a3b975ab84d5565344b62/pnpm-lock.yaml should have everything you need here

Yea, and no one else has reported issues with this, which makes me think that it is related to the provider having awareness setup on it or not. I'll open a PR with some changes that I think should help, but I just can't validate it for myself right now

nperez0111 avatar Dec 11 '25 12:12 nperez0111

Did the PR resolve your issue? You should be able to run your project with the pkg.pr.new versions in the PR

nperez0111 avatar Dec 12 '25 06:12 nperez0111

@nperez0111 It seems this issue is still occurring. I encountered this issue the other day when I upgraded to React 19. It does not occur in React 18, but it does occur in React 19.

  • React18: https://stackblitz.com/edit/github-qp3au5av-yrnhwaas?file=main.tsx
  • React19: https://stackblitz.com/edit/github-qp3au5av-nyf1l6nt?file=main.tsx

ysds avatar Jan 07 '26 07:01 ysds

@ysds I commented out React.Strictmode in your example and it works for me. So, this is due to Strictmode issue that we are tracking here: https://github.com/TypeCellOS/BlockNote/issues/2106

nperez0111 avatar Jan 07 '26 09:01 nperez0111

@nperez0111 I updated to 0.46.1, but unfortunately, the issue does not seem to have been resolved. https://stackblitz.com/edit/github-qp3au5av-gknouchr?file=package.json

ysds avatar Jan 13 '26 03:01 ysds

I was able to reproduce it locally as well.

pnpm --dir ./examples/07-collaboration/01-partykit dev

It doesn’t seem to be reproducible with pnpm dev run from the root directory.

ysds avatar Jan 14 '26 01:01 ysds