Skip to content

Collaboration: accepting/rejecting AI changes throws "reading 'nodeSize'" when another client has a cursor (ForkYDoc merge) #3135

Description

@Movm

What’s broken?

In a collaborative editor (withCollaboration), accepting or rejecting AI changes throws while another client has a cursor in awareness:

TypeError: Cannot read properties of undefined (reading 'nodeSize')
    at relativePositionToAbsolutePosition (y-prosemirror/src/lib.js)
    at createDecorations (y-prosemirror/src/plugins/cursor-plugin.js)   ← Map.forEach over awareness.getStates()
    at Plugin.apply (cursor-plugin.js)
    at EditorState.applyTransaction
    at ProsemirrorBinding._forceRerender (sync-plugin.js)               ← new ySync view, dispatches { binding, isChangeOrigin }
    at EditorView.updatePluginViews
    at BlockNoteEditor.replaceExtension
    at ForkYDocExtension.merge                                          ← from AIExtension.rejectChanges / acceptChanges

The exception escapes merge() midway, so forkedState / isForked are never reset and the editor stays in a half-merged state.

Root cause. This is the remaining gap of #2952. merge() swaps the yjs plugins via replaceExtension and calls bindYSyncPluginStateTo(editor, originalFragment) afterwards. But during replaceExtension the new ySync view re-renders synchronously and dispatches { binding, isChangeOrigin: true }. For that one transaction the carried-over ySync plugin state is torn:

{ type: forkedFragment, doc: forkedDoc, binding: newBinding(originalFragment) }

The freshly re-added yCursorPlugin reacts to isChangeOrigin and calls createDecorations. It resolves every remote cursor with ystate.doc / ystate.type (the fork) but walks siblings through ystate.binding.mapping (keyed by the original fragment's types), so mapping.get(forkType) is undefined → .nodeSize throws.

fork() has the same ordering, but it doesn't crash because it doesn't re-add the cursor plugin.

Pre-binding the state before replaceExtension does not fix it. It only moves the tear: the new cursor plugin's init runs during the same reconfigure against the still-active fork binding, and throws the same error with the opposite mismatch. The state has to change together with the binding, in the transaction that installs it.

What did you expect to happen?

Accept/reject completes and remote cursors render against the original document.

Steps to reproduce

Minimal repro (vitest, jsdom, BlockNote 0.55.0, y-prosemirror 1.3.7). The fake awareness exposes the five members YCursorExtension touches:

// @vitest-environment jsdom
import { BlockNoteEditor } from "@blocknote/core";
import { ForkYDocExtension, withCollaboration } from "@blocknote/core/yjs";
import * as Y from "yjs";
import { expect, it } from "vitest";

it.each([false, true])("merge({ keepChanges: %s }) with a remote cursor", (keepChanges) => {
  const doc = new Y.Doc();
  const fragment = doc.getXmlFragment("doc");
  const states = new Map<number, any>();
  let local: any = {};
  const awareness = {
    getStates: () => states,
    getLocalState: () => local,
    setLocalStateField: (k: string, v: unknown) => (local = { ...local, [k]: v }),
    on: () => {},
    off: () => {},
  };
  const editor = BlockNoteEditor.create(
    withCollaboration({
      collaboration: { fragment, user: { name: "A", color: "#ff0000" }, provider: { awareness } as any },
    }),
  );
  editor.mount(document.createElement("div"));
  editor.replaceBlocks(editor.document, [
    { type: "paragraph", content: "one" },
    { type: "paragraph", content: "two" },
  ]);

  // another client with a cursor at the end of the document
  const end = Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(fragment, fragment.length));
  states.set(424242, { user: { name: "B", color: "#00ff00" }, cursor: { anchor: end, head: end } });

  const fork = editor.getExtension(ForkYDocExtension)!;
  fork.fork();
  expect(() => fork.merge({ keepChanges })).not.toThrow(); // throws "reading 'nodeSize'"
});

In the app: open a collaborative doc in two tabs (or with a second user), place the cursor in tab B, run any AI command in tab A, then click Accept or Discard.

BlockNote version

0.54.0 and 0.55.0 (ForkYDoc.ts on main is unchanged since #2952)

Environment

Chrome 153 / macOS (seen in production), reproduced in vitest + jsdom

Additional context

Workaround we ship. A TipTap dispatchTransaction middleware adds type/doc from the binding to the ySync meta that installs it. That's exactly what bindYSyncPluginStateTo sets one step later, so the three fields are consistent at every point of fork and merge:

Extension.create({
  name: "ySyncBindingConsistency",
  dispatchTransaction({ transaction: tr, next }) {
    const meta = tr.getMeta(ySyncPluginKey);
    const bound = meta?.binding?.type;
    if (meta && bound && !("type" in meta)) {
      const current = ySyncPluginKey.getState(this.editor.state);
      if (current && current.type !== bound) {
        tr.setMeta(ySyncPluginKey, { ...meta, type: bound, doc: bound.doc });
      }
    }
    next(tr);
  },
});

An upstream fix could make the same change at the source. For example, y-prosemirror's _forceRerender / view init could include type/doc in the meta it dispatches, or ForkYDocExtension could make the swap and the rebind a single step.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions