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])); + }); + }); });