From 5c31771a7d3afb504d77a100de1aff3fba84af03 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 10 Mar 2026 17:12:13 -0700 Subject: [PATCH] fix(super-editor): preserve root doc attrs during collaboration seeding --- .../core/Editor.collaboration-seed.test.ts | 91 +++++++++++++++++++ packages/super-editor/src/core/Editor.ts | 42 +++++++-- .../setSectionPageMarginsAtSelection.js | 3 +- .../contract-conformance.test.ts | 1 + .../helpers/section-mutation-wrapper.ts | 3 +- .../document-api-adapters/sections-adapter.ts | 3 +- .../extensions/collaboration/collaboration.js | 6 +- .../collaboration/collaboration.test.js | 7 +- 8 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 packages/super-editor/src/core/Editor.collaboration-seed.test.ts diff --git a/packages/super-editor/src/core/Editor.collaboration-seed.test.ts b/packages/super-editor/src/core/Editor.collaboration-seed.test.ts new file mode 100644 index 000000000..4ee247fc5 --- /dev/null +++ b/packages/super-editor/src/core/Editor.collaboration-seed.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { Doc as YDoc } from 'yjs'; +import { Editor } from './Editor.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { getTestDataAsFileBuffer } from '@tests/helpers/helpers.js'; + +type SyncHandler = (synced?: boolean) => void; + +function createProviderStub() { + const listeners = { + sync: new Set(), + synced: new Set(), + }; + + return { + synced: false, + isSynced: false, + on(event: 'sync' | 'synced', handler: SyncHandler) { + listeners[event].add(handler); + }, + off(event: 'sync' | 'synced', handler: SyncHandler) { + listeners[event].delete(handler); + }, + emit(event: 'sync' | 'synced', value?: boolean) { + for (const handler of listeners[event]) { + handler(value); + } + }, + }; +} + +function createTestEditor(options: Partial[0]> = {}) { + return new Editor({ + isHeadless: true, + deferDocumentLoad: true, + mode: 'docx', + extensions: getStarterExtensions(), + suppressDefaultDocxStyles: true, + ...options, + }); +} + +describe('Editor collaboration seeding', () => { + let centeredBuffer: Buffer; + + beforeAll(async () => { + centeredBuffer = await getTestDataAsFileBuffer('advanced-text.docx'); + }); + + it('preserves the first paragraph attrs when seeding a collaborative room', async () => { + const provider = createProviderStub(); + const ydoc = new YDoc(); + const seededEditor = createTestEditor({ + ydoc, + collaborationProvider: provider, + }); + const directEditor = createTestEditor(); + + try { + await seededEditor.open(centeredBuffer, { + mode: 'docx', + isNewFile: true, + }); + await directEditor.open(centeredBuffer, { + mode: 'docx', + }); + + provider.emit('synced', true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const seededFirstParagraph = seededEditor.state.doc.firstChild; + const directFirstParagraph = directEditor.state.doc.firstChild; + + expect(seededFirstParagraph?.textContent).toBe(directFirstParagraph?.textContent); + expect(seededFirstParagraph?.attrs?.paraId).toBe(directFirstParagraph?.attrs?.paraId); + expect(seededFirstParagraph?.attrs?.paragraphProperties?.justification).toBe( + directFirstParagraph?.attrs?.paragraphProperties?.justification, + ); + expect(seededFirstParagraph?.attrs?.attributes ?? null).toEqual(directFirstParagraph?.attrs?.attributes ?? null); + } finally { + if (seededEditor.lifecycleState === 'ready') { + seededEditor.close(); + } + if (directEditor.lifecycleState === 'ready') { + directEditor.close(); + } + seededEditor.destroy(); + directEditor.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 3336a5858..81aa11a23 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -1529,6 +1529,38 @@ export class Editor extends EventEmitter { this.#dispatchTransaction(tr); } + /** + * Sync root-level document attrs without mutating the first top-level node. + */ + #syncDocumentAttrs(nextAttrs: Record = {}): void { + const currentAttrs = (this.state.doc?.attrs ?? {}) as Record; + const docAttrSpecs = (this.schema?.topNodeType?.spec?.attrs ?? {}) as Record; + const attrKeys = new Set([...Object.keys(docAttrSpecs), ...Object.keys(currentAttrs), ...Object.keys(nextAttrs)]); + + if (attrKeys.size === 0) return; + + const valuesMatch = (a: unknown, b: unknown): boolean => a === b || JSON.stringify(a) === JSON.stringify(b); + + const tr = this.state.tr.setMeta('addToHistory', false); + let changed = false; + + for (const key of attrKeys) { + const hasNextValue = Object.prototype.hasOwnProperty.call(nextAttrs, key); + const nextValue = hasNextValue ? nextAttrs[key] : docAttrSpecs[key]?.default; + + if (valuesMatch(currentAttrs[key], nextValue)) { + continue; + } + + tr.setDocAttribute(key, nextValue); + changed = true; + } + + if (changed) { + this.#dispatchTransaction(tr); + } + } + /** * Replace the current document with new data. Necessary for initializing a new collaboration file, * since we need to insert the data only after the provider has synced. @@ -1547,15 +1579,7 @@ export class Editor extends EventEmitter { ydoc.getMap('meta').set('bodySectPr', nextBodySectPr); } - if (Object.keys(doc.attrs).length > 0) { - const attrsTr = this.state.tr - .setNodeMarkup(0, undefined, { - ...(this.state.doc.attrs ?? {}), - ...(doc.attrs ?? {}), - }) - .setMeta('addToHistory', false); - this.#dispatchTransaction(attrsTr); - } + this.#syncDocumentAttrs((doc.attrs ?? {}) as Record); setTimeout(() => { this.#initComments(); diff --git a/packages/super-editor/src/core/commands/setSectionPageMarginsAtSelection.js b/packages/super-editor/src/core/commands/setSectionPageMarginsAtSelection.js index f7c4a4b23..3f9a96201 100644 --- a/packages/super-editor/src/core/commands/setSectionPageMarginsAtSelection.js +++ b/packages/super-editor/src/core/commands/setSectionPageMarginsAtSelection.js @@ -137,8 +137,7 @@ export const setSectionPageMarginsAtSelection = } // Write updated body sectPr onto the doc attrs so layout sees it immediately - const nextDocAttrs = { ...docAttrs, bodySectPr: sectPr }; - tr.setNodeMarkup(0, undefined, nextDocAttrs); + tr.setDocAttribute('bodySectPr', sectPr); tr.setMeta('forceUpdatePagination', true); return true; diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index d3c90a265..8f7caa1c3 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -1356,6 +1356,7 @@ function makeSectionsEditor(options: SectionEditorOptions = {}): Editor { return tr; }), setNodeMarkup: vi.fn(() => tr), + setDocAttribute: vi.fn(() => tr), setMeta: vi.fn(() => tr), mapping: { maps: [] as unknown[], diff --git a/packages/super-editor/src/document-api-adapters/helpers/section-mutation-wrapper.ts b/packages/super-editor/src/document-api-adapters/helpers/section-mutation-wrapper.ts index 5676c3cab..98e008285 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/section-mutation-wrapper.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/section-mutation-wrapper.ts @@ -106,9 +106,8 @@ export function applySectPrToProjection(editor: Editor, projection: SectionProje return; } - const docAttrs = (editor.state.doc.attrs ?? {}) as Record; const tr = applyDirectMutationMeta(editor.state.tr); - tr.setNodeMarkup(0, undefined, { ...docAttrs, bodySectPr: sectPr }); + tr.setDocAttribute('bodySectPr', sectPr); tr.setMeta('forceUpdatePagination', true); editor.dispatch(tr); syncConverterBodySection(editor, sectPr); diff --git a/packages/super-editor/src/document-api-adapters/sections-adapter.ts b/packages/super-editor/src/document-api-adapters/sections-adapter.ts index 73384fe97..ca9c0dda2 100644 --- a/packages/super-editor/src/document-api-adapters/sections-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/sections-adapter.ts @@ -245,9 +245,8 @@ function applySectPrToProjection(editor: Editor, projection: SectionProjection, return; } - const docAttrs = (editor.state.doc.attrs ?? {}) as Record; const tr = applyDirectMutationMeta(editor.state.tr); - tr.setNodeMarkup(0, undefined, { ...docAttrs, bodySectPr: sectPr }); + tr.setDocAttribute('bodySectPr', sectPr); tr.setMeta('forceUpdatePagination', true); editor.dispatch(tr); syncConverterBodySection(editor, sectPr); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index e8165c49c..a2453f21f 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -70,12 +70,8 @@ const applyBodySectPrFromMetaMap = (editor, ydoc) => { if (!editor?.state?.tr) return false; - const nextDocAttrs = { - ...(editor.state.doc?.attrs ?? {}), - bodySectPr: nextBodySectPr, - }; const tr = editor.state.tr - .setNodeMarkup(0, undefined, nextDocAttrs) + .setDocAttribute('bodySectPr', nextBodySectPr) .setMeta('addToHistory', false) .setMeta(BODY_SECT_PR_SYNC_META_KEY, true); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index 81179257e..c0ed79dfd 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -213,7 +213,7 @@ describe('collaboration extension', () => { ydoc._maps.metas.store.set('bodySectPr', bodySectPr); const tr = { - setNodeMarkup: vi.fn(() => tr), + setDocAttribute: vi.fn(() => tr), setMeta: vi.fn(() => tr), }; const editor = { @@ -242,10 +242,7 @@ describe('collaboration extension', () => { ydoc._maps.metas._trigger(new Map([['bodySectPr', {}]])); - expect(tr.setNodeMarkup).toHaveBeenCalledWith(0, undefined, { - attributes: null, - bodySectPr, - }); + expect(tr.setDocAttribute).toHaveBeenCalledWith('bodySectPr', bodySectPr); expect(tr.setMeta).toHaveBeenCalledWith('addToHistory', false); expect(tr.setMeta).toHaveBeenCalledWith('bodySectPrSync', true); expect(editor.dispatch).toHaveBeenCalledWith(tr);