From 416768b9eb7dbb5e8773255e6a97a5ec11ab0ea4 Mon Sep 17 00:00:00 2001 From: Leonid Kuznetsov Date: Mon, 31 Aug 2026 16:28:09 +0300 Subject: [PATCH] fix(super-editor): stop export from stripping tracked marks off the caller's tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The w:ins/w:del decoders removed the tracked-change mark from the node they were handed by assigning the filtered marks array back onto it. That node belongs to the caller: the body export starts from a fresh getUpdatedJson() snapshot each time, but once a header/footer sub-editor is registered, #exportProcessHeadersFooters serializes the converter's persistent import-time tree (this.headers[id] / this.footers[id]) by reference. The first exportDocx() therefore stripped trackInsert/trackDelete permanently, and every later export wrote the header/footer redline as accepted plain text — a counterparty's tracked deletion silently became regular content on the second save, with no user edit involved. Strip the mark on a shallow copy of the node instead, leaving the caller's tree intact, so repeated exports with no intervening edit produce the same tracked changes every time. Fixes #3893 Co-Authored-By: Claude Fable 5 --- .../v3/handlers/w/del/del-translator.js | 8 ++- .../v3/handlers/w/del/del-translator.test.js | 33 ++++++++++ .../v3/handlers/w/ins/ins-translator.js | 8 ++- .../v3/handlers/w/ins/ins-translator.test.js | 33 ++++++++++ .../v1/tests/data/header-tracked-changes.docx | Bin 0 -> 16269 bytes .../headerTrackedChangesDoubleExport.test.js | 62 ++++++++++++++++++ 6 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/data/header-tracked-changes.docx create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/headerTrackedChangesDoubleExport.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index d2106b2539..4c29fd7888 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -88,9 +88,13 @@ function decode(params) { return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - node.marks = marks.filter((m) => m.type !== 'trackDelete'); + // Strip the tracked mark on a copy: `node` belongs to the caller, and the + // header/footer export path passes the converter's persistent import-time + // tree by reference. Mutating it here makes the strip permanent, so the + // second export loses the tracked change entirely (issue #3893). + const strippedNode = { ...node, marks: marks.filter((m) => m.type !== 'trackDelete') }; - const translatedResult = exportSchemaToJson({ ...params, node }); + const translatedResult = exportSchemaToJson({ ...params, node: strippedNode }); if (params.isFinalDoc) { return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 0be0fe48ef..6895e83f74 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -235,6 +235,39 @@ describe('w:del translator', () => { expect(result.elements[0].elements[0].name).toBe('w:delText'); }); + it('strips the tracked mark on a copy without mutating the caller node (#3893)', () => { + // The header/footer export path hands decode the converter's persistent + // import-time tree by reference; an in-place strip makes the second + // export lose the tracked change entirely. + const mockTrackedMark = { + type: 'trackDelete', + attrs: { + id: '123', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + + exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t', text: 'deleted text' }] }); + + const node = { + type: 'text', + text: 'deleted text', + marks: [mockTrackedMark, { type: 'bold' }], + }; + + config.decode({ node }); + + expect(node.marks).toEqual([mockTrackedMark, { type: 'bold' }]); + expect(exportSchemaToJson).toHaveBeenCalledWith( + expect.objectContaining({ + node: expect.objectContaining({ marks: [{ type: 'bold' }] }), + }), + ); + }); + it('renames every in a multi-segment run to (newline split)', () => { const mockTrackedMark = { type: 'trackDelete', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 741dbc9c7d..bd7ab81eec 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -87,9 +87,13 @@ function decode(params) { return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - node.marks = marks.filter((m) => m.type !== 'trackInsert'); + // Strip the tracked mark on a copy: `node` belongs to the caller, and the + // header/footer export path passes the converter's persistent import-time + // tree by reference. Mutating it here makes the strip permanent, so the + // second export loses the tracked change entirely (issue #3893). + const strippedNode = { ...node, marks: marks.filter((m) => m.type !== 'trackInsert') }; - const translatedTextNode = exportSchemaToJson({ ...params, node }); + const translatedTextNode = exportSchemaToJson({ ...params, node: strippedNode }); if (params.isFinalDoc) { return translatedTextNode; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index e1ac8110ad..3d4295ceeb 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -253,6 +253,39 @@ describe('w:ins translator', () => { }); }); + it('strips the tracked mark on a copy without mutating the caller node (#3893)', () => { + // The header/footer export path hands decode the converter's persistent + // import-time tree by reference; an in-place strip makes the second + // export lose the tracked change entirely. + const mockTrackedMark = { + type: 'trackInsert', + attrs: { + id: '123', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + + exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t' }] }); + + const node = { + type: 'text', + text: 'added text', + marks: [mockTrackedMark, { type: 'bold' }], + }; + + config.decode({ node }); + + expect(node.marks).toEqual([mockTrackedMark, { type: 'bold' }]); + expect(exportSchemaToJson).toHaveBeenCalledWith( + expect.objectContaining({ + node: expect.objectContaining({ marks: [{ type: 'bold' }] }), + }), + ); + }); + it('writes sourceId to w:id for round-trip fidelity', () => { const mockTrackedMark = { type: 'trackInsert', diff --git a/packages/super-editor/src/editors/v1/tests/data/header-tracked-changes.docx b/packages/super-editor/src/editors/v1/tests/data/header-tracked-changes.docx new file mode 100644 index 0000000000000000000000000000000000000000..aca116d57556c8b66a07ce46099bc880e944038d GIT binary patch literal 16269 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 DZL6tF literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/editors/v1/tests/import-export/headerTrackedChangesDoubleExport.test.js b/packages/super-editor/src/editors/v1/tests/import-export/headerTrackedChangesDoubleExport.test.js new file mode 100644 index 0000000000..c345ef7f8c --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/headerTrackedChangesDoubleExport.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '../helpers/helpers.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; + +const countTrackNodes = (node, tracker) => { + if (!node || typeof node !== 'object') return; + if (node.name === 'w:ins') tracker.ins += 1; + if (node.name === 'w:del') tracker.del += 1; + if (Array.isArray(node.elements)) node.elements.forEach((child) => countTrackNodes(child, tracker)); +}; + +const loadExportedHeaderCensus = async (exportedBuffer) => { + const zipper = new DocxZipper(); + const exportedFiles = await zipper.getDocxData(exportedBuffer, true); + const headerXmlEntry = exportedFiles.find((entry) => entry.name === 'word/header1.xml'); + expect(headerXmlEntry).toBeDefined(); + + const headerJson = parseXmlToJson(headerXmlEntry.content); + const tracker = { ins: 0, del: 0 }; + countTrackNodes(headerJson, tracker); + return tracker; +}; + +describe('header tracked changes across repeated exports (#3893)', () => { + // Once a header sub-editor is registered (the UI mounts one as soon as the + // user clicks into the header), #exportProcessHeadersFooters serializes the + // converter's persistent import-time header tree. The tracked-change decoders + // used to strip trackInsert/trackDelete marks off that tree in place, so the + // first export was correct and every later export silently dropped the + // header redline — a counterparty's tracked deletion came back as accepted + // plain text. Saving twice with no intervening edit must produce the same + // tracked changes both times. + it('preserves header w:ins/w:del on the second export once a header sub-editor is registered', async () => { + const fileName = 'header-tracked-changes.docx'; + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(fileName); + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + const { editor: headerSubEditor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + }); + + const headerIds = Object.keys(editor.converter.headers); + expect(headerIds.length).toBeGreaterThan(0); + + // Register a sub-editor for every header part, the way + // HeaderFooterEditorManager does when a user clicks into a header; the + // export loop only reads `.editor` off each entry. + headerIds.forEach((id) => { + editor.converter.headerEditors.push({ id, editor: headerSubEditor }); + }); + + const firstExport = await loadExportedHeaderCensus(await editor.exportDocx({ isFinalDoc: false })); + expect(firstExport).toEqual({ ins: 1, del: 1 }); + + const secondExport = await loadExportedHeaderCensus(await editor.exportDocx({ isFinalDoc: false })); + expect(secondExport).toEqual({ ins: 1, del: 1 }); + }); +});