Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"@better-auth/drizzle-adapter": "^1.7.7",
"@better-auth/mcp": "^1.7.7",
"@better-auth/oauth-provider": "^1.7.7",
"@blocknote/xl-ai": "^0.55.0",
"@blocknote/xl-ai": "^0.56.0",
"@crawlee/cheerio": "^3.18.2",
"@crawlee/core": "^3.18.2",
"@crawlee/playwright": "^3.18.2",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"type": "module",
"dependencies": {
"@assistant-ui/react": "^0.15.28",
"@blocknote/core": "^0.55.0",
"@blocknote/xl-docx-exporter": "^0.55.0",
"@blocknote/xl-odt-exporter": "^0.55.0",
"@blocknote/core": "^0.56.0",
"@blocknote/xl-docx-exporter": "^0.56.0",
"@blocknote/xl-odt-exporter": "^0.56.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
Expand Down
8 changes: 4 additions & 4 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
"test": "vitest run"
},
"dependencies": {
"@blocknote/core": "^0.55.0",
"@blocknote/react": "^0.55.0",
"@blocknote/shadcn": "^0.55.0",
"@blocknote/xl-ai": "^0.55.0",
"@blocknote/core": "^0.56.0",
"@blocknote/react": "^0.56.0",
"@blocknote/shadcn": "^0.56.0",
"@blocknote/xl-ai": "^0.56.0",
"@floating-ui/react": "^0.27.20",
"@gruenerator/collab": "workspace:*",
"@gruenerator/ui": "workspace:*",
Expand Down
18 changes: 4 additions & 14 deletions packages/docs/src/components/editor/BlockNoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,13 @@ import { EditorDictationButton } from './EditorDictationButton';
import { SuggestionPopover } from './SuggestionPopover';
import { isDocAIForked } from '../../lib/aiExtension';
import { disableGcOnAIFork } from '../../lib/forkedDocGc';
import { guardDocUndoAcrossAIFork, type UndoGuardEditor } from '../../lib/undoAcrossAIFork';
import { guardDocUndoAcrossAIFork } from '../../lib/undoAcrossAIFork';
import { carryUndoAcrossEditorRecreate } from '../../lib/undoAcrossRecreate';
import { type UndoableEditor } from '../../hooks/useDocUndoState';
import { SuggestChangesExtension } from '../../lib/suggestChangesExtension';
import { withStructuralUpdateFallback } from '../../lib/structuralUpdateFallback';
import { HoldTableFixesExtension } from '../../lib/holdTableFixes';
import { isDocAIInvocationInFlight } from '../../lib/invokeDocumentAI';
import { YSyncBindingConsistencyExtension } from '../../lib/ySyncBindingConsistency';
import { useSuggestionMode } from '../../hooks/useSuggestionMode';
import './BlockNoteEditor.css';

Expand Down Expand Up @@ -261,12 +260,6 @@ const BlockNoteEditorInner = ({
HoldTableFixesExtension({ isAIWriting: () => isDocAIInvocationInFlight(documentId) }),
];

// The AI fork/merge plugin swap briefly tears the ySync state; a
// collaborator's cursor then crashes accept/reject (GlitchTip #659).
if (collaborationOptions) {
exts.push(YSyncBindingConsistencyExtension);
}

if (showComments && threadStore) {
exts.push(
CommentsExtension({
Expand Down Expand Up @@ -394,12 +387,9 @@ const BlockNoteEditorInner = ({
isCollaborative: Boolean(collaborationOptions),
});

// The same fork/merge cycle destroys the yUndo UndoManager while ProseMirror
// carries it over in the plugin state — without this guard, undo/redo is
// dead for the rest of the session after the first AI invocation. With the
// fragment it additionally captures the accepted AI merge as an undo step
// (#3261). See guardDocUndoAcrossAIFork.
const stopUndoGuard = guardDocUndoAcrossAIFork(editor as unknown as UndoGuardEditor, {
// BlockNote does not record the accepted AI merge on the undo stack; the
// guard makes it one undo step (#3261). See guardDocUndoAcrossAIFork.
const stopUndoGuard = guardDocUndoAcrossAIFork(editor as unknown as UndoableEditor, {
isCollaborative: Boolean(collaborationOptions),
fragment: fragment ?? null,
});
Expand Down
3 changes: 1 addition & 2 deletions packages/docs/src/hooks/useDocUndoState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ export interface UndoableEditor {
* into y-prosemirror internals. Returns null when the plugin isn't registered
* (non-collaborative editor, or the transient window inside the fork/merge
* cycle). Reused by {@link getDocUndoFlags} (reads the stack) and by
* guardDocUndoAcrossAIFork (rebuilds and restacks the manager across the AI
* fork/merge cycle).
* guardDocUndoAcrossAIFork (pushes the accepted AI merge onto the stack).
*/
export function getDocUndoManager(editor: UndoableEditor | null) {
if (!editor) return null;
Expand Down
703 changes: 703 additions & 0 deletions packages/docs/src/lib/__fixtures__/yjsDoc-blocknote-0.55.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { ySyncPluginKey } from 'y-prosemirror';
import * as Y from 'yjs';
import { afterEach, describe, expect, it } from 'vitest';

import { YSyncBindingConsistencyExtension } from './ySyncBindingConsistency';

const REMOTE_CLIENT = 424242;

/** Awareness stand-in (see undoAcrossAIFork.vitest.ts) that can hold a remote peer. */
Expand All @@ -25,13 +23,12 @@ function fakeAwareness() {
};
}

function createCollabEditor({ guarded }: { guarded: boolean }) {
function createCollabEditor() {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment('doc');
const awareness = fakeAwareness();
const editor = BlockNoteEditor.create(
withCollaboration({
extensions: guarded ? [YSyncBindingConsistencyExtension] : [],
collaboration: {
fragment,
user: { name: 'Test User', color: '#FF0000' },
Expand Down Expand Up @@ -79,25 +76,15 @@ afterEach(() => {
ctx = undefined;
});

describe('remote cursors across the AI merge (no guard — documents the upstream bug)', () => {
// merge() swaps the yjs plugins back via replaceExtension; the new ySync view
// re-renders and dispatches `{ binding }` before BlockNote rebinds `type`/`doc`.
// The re-added cursor plugin resolves the collaborator's cursor in the fork
// doc against the original mapping (GlitchTip #659). If this test ever fails,
// upstream fixed it and the extension can go.
it.each([false, true])(
'merge({ keepChanges: %s }) throws while a collaborator has a cursor',
(keepChanges) => {
ctx = createCollabEditor({ guarded: false });
forkExt(ctx.editor).fork();
expect(() => forkExt(ctx!.editor).merge({ keepChanges })).toThrow(/nodeSize/);
}
);
});

describe('YSyncBindingConsistencyExtension', () => {
// Up to 0.55, merge() dispatched the new ySync binding before rebinding the
// plugin state's `type`/`doc`, and the cursor plugin then resolved a
// collaborator's cursor against the wrong doc and threw out of merge() midway
// (GlitchTip #659, TypeCellOS/BlockNote#3135). We patched the transaction with
// YSyncBindingConsistencyExtension; 0.56 resets the plugin state itself
// (#3090). These pin the fix so a regression fails here, not on accept/reject.
describe('AI merge while a collaborator has a cursor', () => {
it.each([false, true])('merge({ keepChanges: %s }) completes and re-syncs', (keepChanges) => {
ctx = createCollabEditor({ guarded: true });
ctx = createCollabEditor();
const { editor, fragment } = ctx;

forkExt(editor).fork();
Expand All @@ -121,7 +108,7 @@ describe('YSyncBindingConsistencyExtension', () => {
});

it('survives repeated fork/merge cycles', () => {
ctx = createCollabEditor({ guarded: true });
ctx = createCollabEditor();
const { editor } = ctx;
for (const keepChanges of [false, true, false]) {
forkExt(editor).fork();
Expand Down
9 changes: 4 additions & 5 deletions packages/docs/src/lib/reviewDocumentAI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,10 @@ export function acceptDocumentAI(documentId: string): AcceptDocumentAIResult {
ydoc?.on('update', onUpdate);

// Note on undoability: the plugin's own UndoManager cannot capture the AI
// merge (ForkYDoc.merge() applies it while the carried-over manager is
// already destroyed; an earlier beforeTransaction/addTrackedOrigin hook here
// provably never worked). guardDocUndoAcrossAIFork owns this instead: a
// standalone capture manager on the original fragment records the merge and
// hands it to the rebuilt manager, so accept IS one undo step (#3261).
// merge (ForkYDoc.merge() applies it under the editor origin, which
// y-prosemirror does not track). guardDocUndoAcrossAIFork owns this instead:
// a standalone capture manager on the original fragment records the merge
// and hands it to the plugin manager, so accept IS one undo step (#3261).
try {
ext.acceptChanges();
} finally {
Expand Down
129 changes: 129 additions & 0 deletions packages/docs/src/lib/storedDocCompat.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import {
BlockNoteEditor,
BlockNoteSchema,
createInlineContentSpec,
defaultBlockSpecs,
defaultInlineContentSpecs,
defaultStyleSpecs,
} from '@blocknote/core';
import { withCollaboration } from '@blocknote/core/yjs';
import * as Y from 'yjs';
import { afterEach, describe, expect, it } from 'vitest';

import fixture055 from './__fixtures__/yjsDoc-blocknote-0.55.json';

/**
* Every stored document was written by an older BlockNote. Opening it in the
* current one must not change it: y-prosemirror writes whatever the editor
* normalises straight back into the shared Y.Doc, so a schema change that drops
* or rewrites a node on load would rewrite the document for every collaborator,
* and the next store persists it. The fixture covers every block, style and
* inline type our schema allows (nesting, toggles, tables with spans and
* colours, code with and without language, media, mention), written by 0.55.
*
* When a BlockNote upgrade turns this red, stop and find out what changed in
* the stored shape before shipping — do not regenerate the fixture to make it
* pass. Add a fixture for the new version next to it instead.
*/

// Same ProseMirror node as Mention.tsx; the React render is not needed here.
const Mention = createInlineContentSpec(
{
type: 'mention',
propSchema: { userId: { default: '' }, userName: { default: 'Unbekannt' } },
content: 'none',
},
{
render: (ic) => {
const span = document.createElement('span');
span.textContent = `@${ic.props.userName}`;
return { dom: span };
},
}
);

const schema = BlockNoteSchema.create({
blockSpecs: defaultBlockSpecs,
inlineContentSpecs: { ...defaultInlineContentSpecs, mention: Mention },
styleSpecs: defaultStyleSpecs,
});

function fakeAwareness() {
const states = new Map<number, Record<string, unknown>>();
let local: Record<string, unknown> = {};
return {
getStates: () => states,
getLocalState: () => local,
setLocalStateField: (k: string, v: unknown) => {
local = { ...local, [k]: v };
},
on: () => {},
off: () => {},
};
}

const editors: BlockNoteEditor<any, any, any>[] = [];

afterEach(() => {
editors.splice(0).forEach((e) => e.unmount());
});

// The fixture went through JSON, so `undefined` fields are gone and sparse
// column widths are `null`; compare the editor's blocks in the same form.
const asStored = (blocks: unknown) => JSON.parse(JSON.stringify(blocks)) as unknown;

function openStored(update: Uint8Array) {
const doc = new Y.Doc();
Y.applyUpdate(doc, update);
const fragment = doc.getXmlFragment(fixture055.fragment);
const xmlBefore = fragment.toString();
const writes: Uint8Array[] = [];
doc.on('update', (u: Uint8Array) => writes.push(u));

const editor = BlockNoteEditor.create(
withCollaboration({
schema,
tables: { splitCells: true, cellBackgroundColor: true, cellTextColor: true, headers: true },
collaboration: {
fragment,
user: { name: 'Test User', color: '#FF0000' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: { awareness: fakeAwareness() } as any,
},
})
);
editors.push(editor);
editor.mount(document.createElement('div'));
return { doc, fragment, editor, xmlBefore, writes };
}

describe('documents stored by an older BlockNote', () => {
const update = new Uint8Array(Buffer.from(fixture055.update, 'base64'));

it(`opens a ${fixture055.writtenBy} document without writing to it`, async () => {
const { fragment, xmlBefore, writes } = openStored(update);
await new Promise((r) => setTimeout(r, 50));

expect(writes).toHaveLength(0);
expect(fragment.toString()).toBe(xmlBefore);
});

it(`reads every block of a ${fixture055.writtenBy} document unchanged`, () => {
const { editor } = openStored(update);
expect(asStored(editor.document)).toEqual(fixture055.blocks);
});

it('an edit by the current version keeps the stored blocks intact', () => {
const { doc, editor } = openStored(update);
editor.insertBlocks(
[{ type: 'paragraph', content: 'Neu' }],
editor.document[editor.document.length - 1],
'after'
);

const reopened = openStored(Y.encodeStateAsUpdate(doc)).editor.document;
expect(asStored(reopened.slice(0, fixture055.blocks.length))).toEqual(fixture055.blocks);
expect(reopened).toHaveLength(fixture055.blocks.length + 1);
});
});
Loading
Loading