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.
What’s broken?
In a collaborative editor (
withCollaboration), accepting or rejecting AI changes throws while another client has a cursor in awareness:The exception escapes
merge()midway, soforkedState/isForkedare 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 viareplaceExtensionand callsbindYSyncPluginStateTo(editor, originalFragment)afterwards. But duringreplaceExtensionthe new ySync view re-renders synchronously and dispatches{ binding, isChangeOrigin: true }. For that one transaction the carried-over ySync plugin state is torn:The freshly re-added
yCursorPluginreacts toisChangeOriginand callscreateDecorations. It resolves every remote cursor withystate.doc/ystate.type(the fork) but walks siblings throughystate.binding.mapping(keyed by the original fragment's types), somapping.get(forkType)isundefined→.nodeSizethrows.fork()has the same ordering, but it doesn't crash because it doesn't re-add the cursor plugin.Pre-binding the state before
replaceExtensiondoes not fix it. It only moves the tear: the new cursor plugin'sinitruns during the samereconfigureagainst 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
YCursorExtensiontouches: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.tsonmainis unchanged since #2952)Environment
Chrome 153 / macOS (seen in production), reproduced in vitest + jsdom
Additional context
Workaround we ship. A TipTap
dispatchTransactionmiddleware addstype/docfrom the binding to the ySync meta that installs it. That's exactly whatbindYSyncPluginStateTosets one step later, so the three fields are consistent at every point of fork and merge:An upstream fix could make the same change at the source. For example, y-prosemirror's
_forceRerender/ view init could includetype/docin the meta it dispatches, orForkYDocExtensioncould make the swap and the rebind a single step.