diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-headless.integration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration-headless.integration.test.js
new file mode 100644
index 0000000000..957fde58bb
--- /dev/null
+++ b/packages/super-editor/src/extensions/collaboration/collaboration-headless.integration.test.js
@@ -0,0 +1,235 @@
+/**
+ * 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 editors;
+
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+ const waitFor = async (predicate, { timeoutMs = 1000, intervalMs = 10 } = {}) => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (predicate()) return;
+ await wait(intervalMs);
+ }
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
+ };
+
+ const createHeadlessEditor = (overrides = {}) => {
+ const nextEditor = new Editor({
+ isHeadless: true,
+ mode: 'docx',
+ documentId: 'test-headless',
+ extensions: getStarterExtensions(),
+ ydoc,
+ content: [],
+ mediaFiles: {},
+ fonts: {},
+ ...overrides,
+ });
+ editors.push(nextEditor);
+ return nextEditor;
+ };
+
+ const waitForEditorText = async (targetEditor, text, timeoutMs = 1000) => {
+ await waitFor(() => targetEditor.state.doc.textContent.includes(text), { timeoutMs });
+ };
+
+ beforeEach(() => {
+ ydoc = new YDoc({ gc: false });
+ editors = [];
+ });
+
+ afterEach(() => {
+ for (const currentEditor of editors.reverse()) {
+ currentEditor.destroy();
+ }
+ editors = [];
+ if (ydoc) {
+ ydoc.destroy();
+ ydoc = null;
+ }
+ });
+
+ it('initializes Y.js binding in headless mode', () => {
+ const editor = createHeadlessEditor({ documentId: 'test-headless-binding' });
+
+ // 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 () => {
+ const editor = createHeadlessEditor({ documentId: 'test-no-loop' });
+
+ 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', () => {
+ const editor = createHeadlessEditor({ documentId: 'test-headless-edits' });
+
+ 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
+ const editor = createHeadlessEditor({
+ documentId: 'test-local-ydoc',
+ // No collaborationProvider - user manages it externally
+ });
+
+ 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');
+ });
+
+ it('rehydrates a headless editor from pre-populated Y.js content', async () => {
+ const seedEditor = createHeadlessEditor({ documentId: 'test-rehydrate-seed' });
+ seedEditor.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Seeded collaborative content' }],
+ });
+
+ const reopenedEditor = createHeadlessEditor({ documentId: 'test-rehydrate-open' });
+ await waitForEditorText(reopenedEditor, 'Seeded collaborative content');
+
+ expect(reopenedEditor.state.doc.textContent).toContain('Seeded collaborative content');
+ });
+
+ it('preserves existing collaborative content on first local edit after headless reopen', async () => {
+ const seedEditor = createHeadlessEditor({ documentId: 'test-preserve-seed' });
+ seedEditor.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Existing shared text' }],
+ });
+
+ const reopenedEditor = createHeadlessEditor({ documentId: 'test-preserve-reopen' });
+ await waitForEditorText(reopenedEditor, 'Existing shared text');
+
+ reopenedEditor.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'First local edit' }],
+ });
+
+ const observerEditor = createHeadlessEditor({ documentId: 'test-preserve-observer' });
+ await waitForEditorText(observerEditor, 'Existing shared text');
+ await waitForEditorText(observerEditor, 'First local edit');
+
+ expect(observerEditor.state.doc.textContent).toContain('Existing shared text');
+ expect(observerEditor.state.doc.textContent).toContain('First local edit');
+ });
+
+ it('syncs edits bidirectionally between two active headless editors', async () => {
+ const editorA = createHeadlessEditor({ documentId: 'test-bidirectional-a' });
+ const editorB = createHeadlessEditor({ documentId: 'test-bidirectional-b' });
+
+ editorA.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Edit from A' }],
+ });
+ await waitForEditorText(editorB, 'Edit from A');
+
+ editorB.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Edit from B' }],
+ });
+ await waitForEditorText(editorA, 'Edit from B');
+
+ expect(editorA.state.doc.textContent).toContain('Edit from A');
+ expect(editorA.state.doc.textContent).toContain('Edit from B');
+ expect(editorB.state.doc.textContent).toContain('Edit from A');
+ expect(editorB.state.doc.textContent).toContain('Edit from B');
+ });
+
+ it('syncs immediate edits dispatched right after construction', async () => {
+ let createEventFired = false;
+ const immediateEditor = createHeadlessEditor({
+ documentId: 'test-immediate-edit-source',
+ onCreate: () => {
+ createEventFired = true;
+ },
+ });
+
+ // The create event is async in headless mode; this edit happens in the same tick.
+ expect(createEventFired).toBe(false);
+ immediateEditor.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Immediate headless edit' }],
+ });
+
+ const observerEditor = createHeadlessEditor({ documentId: 'test-immediate-edit-observer' });
+ await waitForEditorText(observerEditor, 'Immediate headless edit');
+ expect(observerEditor.state.doc.textContent).toContain('Immediate headless edit');
+ });
+
+ it('does not emit Y-origin bounce transactions for a local headless edit', async () => {
+ const editor = createHeadlessEditor({ documentId: 'test-no-y-bounce' });
+ let yOriginTransactionCount = 0;
+
+ editor.on('transaction', ({ transaction }) => {
+ if (transaction.getMeta(ySyncPluginKey)?.isChangeOrigin) {
+ yOriginTransactionCount += 1;
+ }
+ });
+
+ editor.commands.insertContent({
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'No bounce expected' }],
+ });
+
+ await wait(50);
+ expect(yOriginTransactionCount).toBe(0);
+ });
+});
diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js
index 5316087823..cd3d79a9c3 100644
--- a/packages/super-editor/src/extensions/collaboration/collaboration.js
+++ b/packages/super-editor/src/extensions/collaboration/collaboration.js
@@ -1,10 +1,22 @@
import { Extension } from '@core/index.js';
import { PluginKey } from 'prosemirror-state';
import { encodeStateAsUpdate } from 'yjs';
-import { ySyncPlugin, prosemirrorToYDoc } from 'y-prosemirror';
+import { ySyncPlugin, ySyncPluginKey, yUndoPluginKey, prosemirrorToYDoc } from 'y-prosemirror';
import { updateYdocDocxData, applyRemoteHeaderFooterChanges } from '@extensions/collaboration/collaboration-helpers.js';
export const CollaborationPluginKey = new PluginKey('collaboration');
+const headlessBindingStateByEditor = new WeakMap();
+const headlessCleanupRegisteredEditors = new WeakSet();
+
+const registerHeadlessBindingCleanup = (editor, cleanup) => {
+ if (!cleanup || headlessCleanupRegisteredEditors.has(editor)) return;
+
+ headlessCleanupRegisteredEditors.add(editor);
+ editor.once('destroy', () => {
+ cleanup();
+ headlessCleanupRegisteredEditors.delete(editor);
+ });
+};
export const Collaboration = Extension.create({
name: 'collaboration',
@@ -56,9 +68,24 @@ export const Collaboration = Extension.create({
});
});
+ // Headless editors don't create an EditorView, so wire Y.js binding lifecycle here.
+ // Doing this in addPmPlugins ensures sync hooks are active before the first local transaction.
+ if (this.editor.options.isHeadless) {
+ const cleanup = initHeadlessBinding(this.editor);
+ registerHeadlessBindingCleanup(this.editor, cleanup);
+ }
+
return [syncPlugin];
},
+ onCreate() {
+ // Keep this as a fallback for custom lifecycles that may bypass addPmPlugins.
+ if (this.editor.options.isHeadless && this.editor.options.ydoc) {
+ const cleanup = initHeadlessBinding(this.editor);
+ registerHeadlessBindingCleanup(this.editor, cleanup);
+ }
+ },
+
addCommands() {
return {
addImageToCollaboration:
@@ -154,3 +181,133 @@ 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 existing = headlessBindingStateByEditor.get(editor);
+ if (existing?.cleanup) {
+ return existing.cleanup;
+ }
+
+ const state = {
+ binding: null,
+ cleanup: null,
+ warnedMissingBinding: false,
+ };
+ headlessBindingStateByEditor.set(editor, state);
+
+ // 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: () => ({}),
+ },
+ };
+
+ const ensureInitializedBinding = () => {
+ if (!editor.options.ydoc || !editor.state) return null;
+ const syncState = ySyncPluginKey.getState(editor.state);
+ if (!syncState?.binding) {
+ if (!state.warnedMissingBinding) {
+ console.warn('[Collaboration] Headless binding init: no sync state or binding found');
+ state.warnedMissingBinding = true;
+ }
+ return null;
+ }
+
+ state.warnedMissingBinding = false;
+ const binding = syncState.binding;
+ if (state.binding === binding) {
+ return binding;
+ }
+
+ binding.initView(headlessViewShim);
+
+ // ySyncPlugin's view lifecycle forces a rerender on first mount so PM state reflects Yjs.
+ if (typeof binding._forceRerender === 'function') {
+ binding._forceRerender();
+ }
+
+ // Mirror ySyncPlugin's onFirstRender callback behavior for new files in headless mode.
+ if (editor.options.isNewFile) {
+ initializeMetaMap(editor.options.ydoc, editor);
+ }
+
+ state.binding = binding;
+ return binding;
+ };
+
+ // 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 }) => {
+ if (!editor.options.ydoc) return;
+
+ // Skip if this transaction originated from Y.js (avoid infinite loop)
+ const meta = transaction.getMeta(ySyncPluginKey);
+ if (meta?.isChangeOrigin) return;
+
+ const binding = ensureInitializedBinding();
+ if (!binding) return;
+
+ // Sync ProseMirror changes to Y.js
+ if (typeof binding._prosemirrorChanged !== 'function') return;
+ const addToHistory = transaction.getMeta('addToHistory') !== false;
+
+ // Match y-prosemirror view.update behavior for non-history changes.
+ if (!addToHistory) {
+ const undoPluginState = yUndoPluginKey.getState(editor.state);
+ undoPluginState?.undoManager?.stopCapturing?.();
+ }
+
+ const syncToYjs = () => {
+ const ydoc = editor.options.ydoc;
+ if (!ydoc) return;
+
+ ydoc.transact((tr) => {
+ tr?.meta?.set?.('addToHistory', addToHistory);
+ binding._prosemirrorChanged(editor.state.doc);
+ }, ySyncPluginKey);
+ };
+
+ if (typeof binding.mux === 'function') {
+ binding.mux(syncToYjs);
+ return;
+ }
+
+ syncToYjs();
+ };
+
+ editor.on('transaction', transactionHandler);
+ ensureInitializedBinding();
+
+ // Return cleanup function to remove listener on destroy
+ state.cleanup = () => {
+ editor.off('transaction', transactionHandler);
+ if (headlessBindingStateByEditor.get(editor) === state) {
+ headlessBindingStateByEditor.delete(editor);
+ }
+ headlessCleanupRegisteredEditors.delete(editor);
+ };
+ return state.cleanup;
+};
diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js
index 4b26828063..f7b2e3f34f 100644
--- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js
+++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js
@@ -1,9 +1,27 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-vi.mock('y-prosemirror', () => ({
- ySyncPlugin: vi.fn(() => 'y-sync-plugin'),
- prosemirrorToYDoc: vi.fn(),
-}));
+// Mock binding object - we'll configure this in tests
+const mockBinding = {
+ initView: vi.fn(),
+ _forceRerender: vi.fn(),
+ mux: vi.fn((fn) => fn()),
+ _prosemirrorChanged: vi.fn(),
+};
+
+vi.mock('y-prosemirror', () => {
+ const mockSyncPluginKey = {
+ getState: vi.fn(() => ({ binding: mockBinding })),
+ };
+ const mockUndoPluginKey = {
+ getState: vi.fn(() => null),
+ };
+ return {
+ ySyncPlugin: vi.fn(() => 'y-sync-plugin'),
+ ySyncPluginKey: mockSyncPluginKey,
+ yUndoPluginKey: mockUndoPluginKey,
+ prosemirrorToYDoc: vi.fn(),
+ };
+});
vi.mock('yjs', () => ({
encodeStateAsUpdate: vi.fn(() => new Uint8Array([1, 2, 3])),
@@ -658,4 +676,333 @@ describe('collaboration extension', () => {
expect(editor.storage.image.media['word/media/local-image.png']).toBe('base64-local-version');
});
});
+
+ describe('headless mode Y.js sync', () => {
+ const createHeadlessEditor = (overrides = {}) => {
+ const ydoc = overrides.ydoc ?? createYDocStub();
+ const provider = overrides.collaborationProvider ?? { synced: false, on: vi.fn(), off: vi.fn() };
+ const editor = {
+ options: {
+ isHeadless: true,
+ ydoc,
+ collaborationProvider: provider,
+ ...overrides.options,
+ },
+ state: overrides.state ?? { doc: { type: 'doc' } },
+ storage: { image: { media: {} } },
+ emit: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ once: vi.fn(),
+ dispatch: overrides.dispatch ?? vi.fn(),
+ };
+ return { editor, ydoc, provider, context: { editor, options: {} } };
+ };
+
+ const getTransactionListener = (editor) => editor.on.mock.calls.find((call) => call[0] === 'transaction')?.[1];
+
+ const getDestroyCleanup = (editor) => editor.once.mock.calls.find((call) => call[0] === 'destroy')?.[1];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockBinding.initView.mockClear();
+ mockBinding._forceRerender.mockClear();
+ mockBinding.mux.mockClear();
+ mockBinding._prosemirrorChanged.mockClear();
+ YProsemirror.ySyncPluginKey.getState.mockReturnValue({ binding: mockBinding });
+ YProsemirror.yUndoPluginKey.getState.mockReturnValue(null);
+ });
+
+ it('initializes Y.js binding with headless view shim when isHeadless is true', () => {
+ const { context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(mockBinding.initView).toHaveBeenCalledTimes(1);
+ const shimArg = mockBinding.initView.mock.calls[0][0];
+ expect(shimArg).toHaveProperty('state');
+ expect(shimArg).toHaveProperty('dispatch');
+ expect(shimArg).toHaveProperty('hasFocus');
+ expect(shimArg).toHaveProperty('_root');
+ expect(shimArg.hasFocus()).toBe(false);
+ });
+
+ it('does not initialize headless binding when isHeadless is false', () => {
+ const ydoc = createYDocStub();
+ const editorState = { doc: {} };
+ const provider = { synced: false, on: vi.fn(), off: vi.fn() };
+ const editor = {
+ options: {
+ isHeadless: false,
+ ydoc,
+ collaborationProvider: provider,
+ },
+ storage: { image: { media: {} } },
+ emit: vi.fn(),
+ view: { state: editorState, dispatch: vi.fn() },
+ };
+
+ const context = { editor, options: {} };
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(mockBinding.initView).not.toHaveBeenCalled();
+ });
+
+ it('registers transaction listener in headless mode', () => {
+ const { editor, context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(editor.on).toHaveBeenCalledWith('transaction', expect.any(Function));
+ });
+
+ it('forces an initial rerender to hydrate headless state from Y.js', () => {
+ const { context } = createHeadlessEditor({ state: { doc: { type: 'doc', content: [] } } });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(mockBinding.initView).toHaveBeenCalledTimes(1);
+ expect(mockBinding._forceRerender).toHaveBeenCalledTimes(1);
+ });
+
+ it('registers headless PM->Y sync before onCreate lifecycle runs', () => {
+ const { editor, context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+
+ expect(editor.on).toHaveBeenCalledWith('transaction', expect.any(Function));
+ });
+
+ it('syncs PM changes to Y.js via transaction listener', () => {
+ const editorState = { doc: { type: 'doc', content: [] } };
+ const { editor, context } = createHeadlessEditor({ state: editorState });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const transactionListener = getTransactionListener(editor);
+ expect(transactionListener).toBeDefined();
+
+ transactionListener({ transaction: { getMeta: vi.fn().mockReturnValue(null) } });
+
+ expect(mockBinding._prosemirrorChanged).toHaveBeenCalledWith(editorState.doc);
+ });
+
+ it('wraps headless PM->Y sync in the binding mutex', () => {
+ const editorState = { doc: { type: 'doc', content: [] } };
+ const { editor, context } = createHeadlessEditor({ state: editorState });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const transactionListener = getTransactionListener(editor);
+ transactionListener({ transaction: { getMeta: vi.fn().mockReturnValue(null) } });
+
+ expect(mockBinding.mux).toHaveBeenCalledTimes(1);
+ expect(mockBinding._prosemirrorChanged).toHaveBeenCalledWith(editorState.doc);
+ });
+
+ it('propagates addToHistory=false into Y.js transaction meta for headless sync', () => {
+ const ydoc = createYDocStub();
+ const yjsMetaSet = vi.fn();
+ ydoc.transact = vi.fn((fn) => {
+ fn({ meta: { set: yjsMetaSet } });
+ });
+
+ const editorState = { doc: { type: 'doc', content: [] } };
+ const { editor, context } = createHeadlessEditor({ ydoc, state: editorState });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const transactionListener = getTransactionListener(editor);
+ transactionListener({
+ transaction: {
+ getMeta: vi.fn((key) => {
+ if (key === 'addToHistory') return false;
+ return null;
+ }),
+ },
+ });
+
+ expect(ydoc.transact).toHaveBeenCalledWith(expect.any(Function), YProsemirror.ySyncPluginKey);
+ expect(yjsMetaSet).toHaveBeenCalledWith('addToHistory', false);
+ expect(mockBinding._prosemirrorChanged).toHaveBeenCalledWith(editorState.doc);
+ });
+
+ it('stops undo capture for headless transactions marked addToHistory=false', () => {
+ const stopCapturing = vi.fn();
+ YProsemirror.yUndoPluginKey.getState.mockReturnValue({
+ undoManager: {
+ stopCapturing,
+ },
+ });
+
+ const { editor, context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const transactionListener = getTransactionListener(editor);
+ transactionListener({
+ transaction: {
+ getMeta: vi.fn((key) => {
+ if (key === 'addToHistory') return false;
+ return null;
+ }),
+ },
+ });
+
+ expect(stopCapturing).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips sync for transactions originating from Y.js', () => {
+ const { editor, context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const transactionListener = getTransactionListener(editor);
+ transactionListener({ transaction: { getMeta: vi.fn().mockReturnValue({ isChangeOrigin: true }) } });
+
+ expect(mockBinding._prosemirrorChanged).not.toHaveBeenCalled();
+ });
+
+ it('handles missing binding gracefully', () => {
+ YProsemirror.ySyncPluginKey.getState.mockReturnValue(null);
+
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const { context } = createHeadlessEditor();
+
+ Collaboration.config.addPmPlugins.call(context);
+ expect(() => Collaboration.config.onCreate.call(context)).not.toThrow();
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('no sync state or binding found'));
+ consoleSpy.mockRestore();
+ });
+
+ it('headless shim state getter returns current editor state', () => {
+ const initialState = { doc: { type: 'doc', content: 'initial' } };
+ const updatedState = { doc: { type: 'doc', content: 'updated' } };
+ const { editor, context } = createHeadlessEditor({ state: initialState });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const shimArg = mockBinding.initView.mock.calls[0][0];
+ expect(shimArg.state).toBe(initialState);
+
+ editor.state = updatedState;
+ expect(shimArg.state).toBe(updatedState);
+ });
+
+ it('headless shim dispatch calls editor.dispatch', () => {
+ const dispatchMock = vi.fn();
+ const { context } = createHeadlessEditor({ dispatch: dispatchMock });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const shimArg = mockBinding.initView.mock.calls[0][0];
+ const mockTr = { steps: [] };
+ shimArg.dispatch(mockTr);
+
+ expect(dispatchMock).toHaveBeenCalledWith(mockTr);
+ });
+
+ it('cleans up transaction listener on editor destroy', () => {
+ const { editor, context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(editor.once).toHaveBeenCalledWith('destroy', expect.any(Function));
+
+ const cleanupFn = getDestroyCleanup(editor);
+ expect(cleanupFn).toBeDefined();
+
+ const transactionHandler = getTransactionListener(editor);
+ expect(transactionHandler).toBeDefined();
+
+ cleanupFn();
+
+ expect(editor.off).toHaveBeenCalledWith('transaction', transactionHandler);
+ });
+
+ it('does not register duplicate headless listeners when onCreate runs after addPmPlugins', () => {
+ const { editor, context } = createHeadlessEditor();
+
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ const transactionListenerRegistrations = editor.on.mock.calls.filter(([event]) => event === 'transaction');
+ const destroyCleanupRegistrations = editor.once.mock.calls.filter(([event]) => event === 'destroy');
+
+ expect(transactionListenerRegistrations).toHaveLength(1);
+ expect(destroyCleanupRegistrations).toHaveLength(1);
+ expect(mockBinding.initView).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-initializes binding when sync plugin binding changes between transactions', () => {
+ const { editor, context } = createHeadlessEditor({ state: { doc: { type: 'doc', content: [] } } });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(mockBinding.initView).toHaveBeenCalledTimes(1);
+
+ // Simulate a new binding (e.g. after ydoc reconnect)
+ const newBinding = {
+ initView: vi.fn(),
+ _forceRerender: vi.fn(),
+ mux: vi.fn((fn) => fn()),
+ _prosemirrorChanged: vi.fn(),
+ };
+ YProsemirror.ySyncPluginKey.getState.mockReturnValue({ binding: newBinding });
+
+ const transactionListener = getTransactionListener(editor);
+ transactionListener({ transaction: { getMeta: vi.fn().mockReturnValue(null) } });
+
+ // New binding should have been initialized
+ expect(newBinding.initView).toHaveBeenCalledTimes(1);
+ expect(newBinding._forceRerender).toHaveBeenCalledTimes(1);
+ expect(newBinding._prosemirrorChanged).toHaveBeenCalledWith(editor.state.doc);
+ });
+
+ it('cleanup allows fresh binding state on subsequent initHeadlessBinding calls', () => {
+ const { editor, context } = createHeadlessEditor();
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ expect(mockBinding.initView).toHaveBeenCalledTimes(1);
+
+ // Trigger cleanup (simulates editor destroy)
+ const cleanupFn = getDestroyCleanup(editor);
+ cleanupFn();
+
+ // Reset mocks and re-initialize for a fresh editor lifecycle
+ mockBinding.initView.mockClear();
+ mockBinding._forceRerender.mockClear();
+
+ // A second addPmPlugins + onCreate cycle should create a fresh binding
+ const context2 = { editor, options: {} };
+ Collaboration.config.addPmPlugins.call(context2);
+ Collaboration.config.onCreate.call(context2);
+
+ expect(mockBinding.initView).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls initializeMetaMap for new files in headless mode', () => {
+ const ydoc = createYDocStub();
+ const { context } = createHeadlessEditor({
+ ydoc,
+ options: {
+ isNewFile: true,
+ content: { 'word/document.xml': '' },
+ fonts: { 'font1.ttf': new Uint8Array([1]) },
+ mediaFiles: { 'word/media/img.png': new Uint8Array([5]) },
+ },
+ });
+ Collaboration.config.addPmPlugins.call(context);
+ Collaboration.config.onCreate.call(context);
+
+ // initializeMetaMap should have been called, writing to the meta map
+ const metaStore = ydoc._maps.metas.store;
+ expect(metaStore.get('docx')).toEqual({ 'word/document.xml': '' });
+ expect(metaStore.get('fonts')).toEqual({ 'font1.ttf': new Uint8Array([1]) });
+ expect(ydoc._maps.media.set).toHaveBeenCalledWith('word/media/img.png', new Uint8Array([5]));
+ });
+ });
});