From 901f3c62df9fce47da699528940078c2c9a92339 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 2 Feb 2026 18:12:58 -0300 Subject: [PATCH] fix: headless yjs --- ...collaboration-headless.integration.test.js | 139 ++++++++ .../extensions/collaboration/collaboration.js | 74 +++- .../collaboration/collaboration.test.js | 325 +++++++++++++++++- 3 files changed, 533 insertions(+), 5 deletions(-) create mode 100644 packages/super-editor/src/extensions/collaboration/collaboration-headless.integration.test.js 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..ceeaa57358 --- /dev/null +++ b/packages/super-editor/src/extensions/collaboration/collaboration-headless.integration.test.js @@ -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'); + }); +}); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index 5316087823..9b680e7ed1 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -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'); @@ -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: @@ -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); + }; + + editor.on('transaction', transactionHandler); + + // Return cleanup function to remove listener on destroy + return () => { + editor.off('transaction', transactionHandler); + }; +}; diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index 4b26828063..91144eaaa7 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -1,9 +1,21 @@ 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(), + _prosemirrorChanged: vi.fn(), +}; + +vi.mock('y-prosemirror', () => { + const mockSyncPluginKey = { + getState: vi.fn(() => ({ binding: mockBinding })), + }; + return { + ySyncPlugin: vi.fn(() => 'y-sync-plugin'), + ySyncPluginKey: mockSyncPluginKey, + prosemirrorToYDoc: vi.fn(), + }; +}); vi.mock('yjs', () => ({ encodeStateAsUpdate: vi.fn(() => new Uint8Array([1, 2, 3])), @@ -658,4 +670,309 @@ describe('collaboration extension', () => { expect(editor.storage.image.media['word/media/local-image.png']).toBe('base64-local-version'); }); }); + + describe('headless mode Y.js sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBinding.initView.mockClear(); + mockBinding._prosemirrorChanged.mockClear(); + YProsemirror.ySyncPluginKey.getState.mockReturnValue({ binding: mockBinding }); + }); + + it('initializes Y.js binding with headless view shim when isHeadless is true', () => { + const ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + // Headless binding is initialized in onCreate (after state is created) + Collaboration.config.onCreate.call(context); + + // Verify binding.initView was called with the headless shim + 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); + + // Verify binding.initView was NOT called + expect(mockBinding.initView).not.toHaveBeenCalled(); + }); + + it('registers transaction listener in headless mode', () => { + const ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + Collaboration.config.onCreate.call(context); + + // Verify transaction listener was registered + expect(editor.on).toHaveBeenCalledWith('transaction', expect.any(Function)); + }); + + it('syncs PM changes to Y.js via transaction listener', () => { + const ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc', content: [] } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + Collaboration.config.onCreate.call(context); + + // Get the transaction listener + const transactionListener = editor.on.mock.calls.find((call) => call[0] === 'transaction')?.[1]; + expect(transactionListener).toBeDefined(); + + // Simulate a local transaction (not from Y.js) + const mockTransaction = { + getMeta: vi.fn().mockReturnValue(null), + }; + transactionListener({ transaction: mockTransaction }); + + // Verify binding._prosemirrorChanged was called + expect(mockBinding._prosemirrorChanged).toHaveBeenCalledWith(editorState.doc); + }); + + it('skips sync for transactions originating from Y.js', () => { + const ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + Collaboration.config.onCreate.call(context); + + // Get the transaction listener + const transactionListener = editor.on.mock.calls.find((call) => call[0] === 'transaction')?.[1]; + + // Simulate a transaction that originated from Y.js (has isChangeOrigin meta) + const mockTransaction = { + getMeta: vi.fn().mockReturnValue({ isChangeOrigin: true }), + }; + transactionListener({ transaction: mockTransaction }); + + // Verify binding._prosemirrorChanged was NOT called (to avoid infinite loop) + expect(mockBinding._prosemirrorChanged).not.toHaveBeenCalled(); + }); + + it('handles missing binding gracefully', () => { + YProsemirror.ySyncPluginKey.getState.mockReturnValue(null); + + const ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + + // Should not throw + Collaboration.config.addPmPlugins.call(context); + expect(() => Collaboration.config.onCreate.call(context)).not.toThrow(); + + // Should log a warning + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('no sync state or binding found')); + + consoleSpy.mockRestore(); + }); + + it('headless shim state getter returns current editor state', () => { + const ydoc = createYDocStub(); + const initialState = { doc: { type: 'doc', content: 'initial' } }; + const updatedState = { doc: { type: 'doc', content: 'updated' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: initialState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + Collaboration.config.onCreate.call(context); + + const shimArg = mockBinding.initView.mock.calls[0][0]; + + // Initial state + expect(shimArg.state).toBe(initialState); + + // Update editor state + editor.state = updatedState; + + // Shim should return the updated state (it's a getter) + expect(shimArg.state).toBe(updatedState); + }); + + it('headless shim dispatch calls editor.dispatch', () => { + const ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const dispatchMock = vi.fn(); + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + once: vi.fn(), + dispatch: dispatchMock, + }; + + const context = { editor, options: {} }; + 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 ydoc = createYDocStub(); + const editorState = { doc: { type: 'doc' } }; + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const onMock = vi.fn(); + const offMock = vi.fn(); + const onceMock = vi.fn(); + const editor = { + options: { + isHeadless: true, + ydoc, + collaborationProvider: provider, + }, + state: editorState, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: onMock, + off: offMock, + once: onceMock, + dispatch: vi.fn(), + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + Collaboration.config.onCreate.call(context); + + // Verify that cleanup is registered on 'destroy' event + expect(onceMock).toHaveBeenCalledWith('destroy', expect.any(Function)); + + // Get the cleanup function that was registered + const cleanupFn = onceMock.mock.calls.find((call) => call[0] === 'destroy')?.[1]; + expect(cleanupFn).toBeDefined(); + + // Get the transaction handler that was registered + const transactionHandler = onMock.mock.calls.find((call) => call[0] === 'transaction')?.[1]; + expect(transactionHandler).toBeDefined(); + + // Call the cleanup function (simulates editor destroy) + cleanupFn(); + + // Verify that the transaction listener was removed + expect(offMock).toHaveBeenCalledWith('transaction', transactionHandler); + }); + }); });