Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Headless Y.js Collaboration Integration Test
*
* Tests that a headless Editor properly initializes Y.js binding.
* The actual sync behavior depends on y-prosemirror internals and is better
* tested end-to-end with a real collaboration server.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Doc as YDoc } from 'yjs';
import { Editor } from '@core/Editor.js';
import { getStarterExtensions } from '@extensions/index.js';
import { ySyncPluginKey } from 'y-prosemirror';

describe('Headless Y.js Collaboration Integration', () => {
let ydoc;
let editor;

beforeEach(() => {
ydoc = new YDoc({ gc: false });
});

afterEach(() => {
if (editor) {
editor.destroy();
editor = null;
}
if (ydoc) {
ydoc.destroy();
ydoc = null;
}
});

it('initializes Y.js binding in headless mode', () => {
editor = new Editor({
isHeadless: true,
mode: 'docx',
documentId: 'test-headless-binding',
extensions: getStarterExtensions(),
ydoc,
content: [],
mediaFiles: {},
fonts: {},
});

// Get the sync plugin state
const syncState = ySyncPluginKey.getState(editor.state);

// Verify binding was initialized
expect(syncState).toBeDefined();
expect(syncState.binding).toBeDefined();
expect(syncState.binding.prosemirrorView).toBeDefined();
});

it('does not create infinite sync loop when making edits', async () => {
editor = new Editor({
isHeadless: true,
mode: 'docx',
documentId: 'test-no-loop',
extensions: getStarterExtensions(),
ydoc,
content: [],
mediaFiles: {},
fonts: {},
});

let transactionCount = 0;
const originalDispatch = editor.dispatch.bind(editor);
editor.dispatch = (tr) => {
transactionCount++;
return originalDispatch(tr);
};

// Make an edit
editor.commands.insertContent({
type: 'paragraph',
content: [{ type: 'text', text: 'Test' }],
});

// Wait for any potential sync loops
await new Promise((resolve) => setTimeout(resolve, 500));

// Should have very few transactions (1 for insert, maybe 1-2 for sync)
// If there's a loop, this would be hundreds or thousands
expect(transactionCount).toBeLessThan(10);
});

it('allows making edits in headless mode with Y.js', () => {
editor = new Editor({
isHeadless: true,
mode: 'docx',
documentId: 'test-headless-edits',
extensions: getStarterExtensions(),
ydoc,
content: [],
mediaFiles: {},
fonts: {},
});

const initialContent = editor.state.doc.textContent;

// Make edits - this should not throw
editor.commands.insertContent({
type: 'paragraph',
content: [{ type: 'text', text: 'Hello from headless!' }],
});

// Verify edit was applied to editor
expect(editor.state.doc.textContent).toContain('Hello from headless');
expect(editor.state.doc.textContent).not.toBe(initialContent);
});

it('works without collaborationProvider (local-only Y.js)', () => {
// This simulates the customer's use case where they manage their own provider
editor = new Editor({
isHeadless: true,
mode: 'docx',
documentId: 'test-local-ydoc',
extensions: getStarterExtensions(),
ydoc, // Y.js doc without provider
// No collaborationProvider - user manages it externally
content: [],
mediaFiles: {},
fonts: {},
});

const syncState = ySyncPluginKey.getState(editor.state);
expect(syncState.binding).toBeDefined();
expect(syncState.binding.prosemirrorView).toBeDefined();

// Should still be able to make edits
editor.commands.insertContent({
type: 'paragraph',
content: [{ type: 'text', text: 'Local Y.js test' }],
});

expect(editor.state.doc.textContent).toContain('Local Y.js test');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Extension } from '@core/index.js';
import { PluginKey } from 'prosemirror-state';
import { encodeStateAsUpdate } from 'yjs';
import { ySyncPlugin, prosemirrorToYDoc } from 'y-prosemirror';
import { ySyncPlugin, ySyncPluginKey, prosemirrorToYDoc } from 'y-prosemirror';
import { updateYdocDocxData, applyRemoteHeaderFooterChanges } from '@extensions/collaboration/collaboration-helpers.js';

export const CollaborationPluginKey = new PluginKey('collaboration');
Expand Down Expand Up @@ -59,6 +59,17 @@ export const Collaboration = Extension.create({
return [syncPlugin];
},

onCreate() {
// In headless mode, manually initialize the Y.js binding since no EditorView is created
// This must happen in onCreate (after state is created) because we need access to the plugin state
if (this.editor.options.isHeadless && this.editor.options.ydoc) {
const cleanup = initHeadlessBinding(this.editor);
if (cleanup) {
this.editor.once('destroy', cleanup);
}
}
},

addCommands() {
return {
addImageToCollaboration:
Expand Down Expand Up @@ -154,3 +165,64 @@ export const generateCollaborationData = async (editor) => {
await updateYdocDocxData(editor, ydoc);
return encodeStateAsUpdate(ydoc);
};

/**
* Initialize Y.js sync binding for headless mode.
*
* In normal (non-headless) mode, ySyncPlugin's `view` callback calls
* `binding.initView(view)` when the EditorView is created. In headless
* mode, no EditorView exists, so we create a minimal shim that satisfies
* y-prosemirror's requirements.
*
* @param {Editor} editor - The SuperEditor instance in headless mode
* @returns {Function|undefined} Cleanup function to remove event listeners
*/
const initHeadlessBinding = (editor) => {
const syncState = ySyncPluginKey.getState(editor.state);
if (!syncState?.binding) {
console.warn('[Collaboration] Headless binding init: no sync state or binding found');
return;
}

const binding = syncState.binding;

// Create a minimal EditorView shim that satisfies y-prosemirror's interface
// See: y-prosemirror/src/plugins/sync-plugin.js initView() and _typeChanged()
const headlessViewShim = {
get state() {
return editor.state;
},
dispatch: (tr) => {
editor.dispatch(tr);
},
hasFocus: () => false,
// Minimal DOM stubs required by y-prosemirror's renderSnapshot/undo operations
_root: {
getSelection: () => null,
createRange: () => ({}),
},
};

// Initialize the binding with our shim
binding.initView(headlessViewShim);

// Listen for ProseMirror transactions and sync to Y.js
// This replicates the behavior of ySyncPlugin's view.update callback
// Note: _prosemirrorChanged is internal to y-prosemirror but is the recommended
// approach for headless mode (see y-prosemirror issue #75)
const transactionHandler = ({ transaction }) => {
// Skip if this transaction originated from Y.js (avoid infinite loop)
const meta = transaction.getMeta(ySyncPluginKey);
if (meta?.isChangeOrigin) return;

// Sync ProseMirror changes to Y.js
binding._prosemirrorChanged(editor.state.doc);
Comment on lines +213 to +219

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve addToHistory when syncing headless edits

The headless transaction handler calls binding._prosemirrorChanged(editor.state.doc) directly for every local transaction, but it never propagates the ProseMirror addToHistory meta into the Yjs transaction the way the normal ySyncPlugin view.update path does (see the bundled y-prosemirror in examples/integrations/chrome-extension/chrome-extension/lib/superdoc.umd.js, where it wraps _prosemirrorChanged with tr.meta.set("addToHistory", pluginState.addToHistory)). Because Yjs undo capture uses tr.meta.get("addToHistory") (same file) and defaults to true, headless transactions that should be excluded from history (e.g., selection-only or programmatic updates) will still be recorded. That can pollute undo/redo stacks or make non-history changes undoable in headless collaboration. Consider mirroring the view.update meta handling before calling _prosemirrorChanged.

Useful? React with 👍 / 👎.

};

editor.on('transaction', transactionHandler);

// Return cleanup function to remove listener on destroy
return () => {
editor.off('transaction', transactionHandler);
};
};
Loading
Loading