Skip to content

Second exportDocx() drops imported tracked changes from a header/footer (V1) — exportToXmlJson strips marks from the caller's tree in place #3893

Description

@ohcedar

What happened?

Once a header (or footer) sub-editor exists, the first exportDocx() writes the header's imported w:ins / w:del correctly, and every export after that writes the header with the tracked changes gone. No user edit is involved and no warning is raised. The text that was inside a w:del comes back as an ordinary run, so a counterparty's tracked deletion silently becomes accepted content on the second save.

Expected: saving twice with no intervening edit produces the same tracked changes both times.

Actual: export 1 has ins=1 del=1; exports 2 and 3 have ins=0 del=0, and the deleted word DRAFT is written as a plain <w:t>DRAFT</w:t>. Body tracked changes are unaffected throughout (ins=5 del=3 on every export), which is why this is easy to miss.

The precondition is just "a header sub-editor has been created" — which happens as soon as a user clicks into the header.

Mechanism

exportToXmlJson mutates the ProseMirror JSON it is handed in place instead of copying it. Both tracked-change decoders assign a filtered array back onto the caller's own node — dist/chunks/SuperConverter-Yn1xiNA9.es.js:14306 and :14373:

node.marks = marks.filter((m) => m.type !== "trackInsert");

For the body this is harmless, because the body export starts from a fresh editor.getUpdatedJson() snapshot on every call. #exportProcessHeadersFooters instead passes the persistent import-time tree by reference (:89416:89424):

const headerEditor = this.headerEditors.find((item) => item.id === id);
if (!headerEditor) return;
const { result, params } = this.exportToXmlJson({
  data: header,          // ← this is this.headers[id] itself, not a copy
  editor: headerEditor.editor,

So export 1 strips the marks off this.headers[id] permanently, and export 2 re-serializes an already-stripped tree. The if (!headerEditor) return; is why the no-sub-editor arm stays stable. A carbonCopy/structured clone of data — already used elsewhere in the same file — would fix it.

Steps to reproduce

Test fixture: test_contract.docx — synthetic, no third-party content; the script splices a one-insertion / one-deletion counterparty redline into word/header1.xml so the fixture has an imported header redline to lose.

npm i @harbour-enterprises/superdoc@1.46.1 jsdom fflate
node repro.mjs test_contract.docx
// repro.mjs
import fs from "node:fs";
import { JSDOM } from "jsdom";
import { unzipSync, zipSync, strFromU8, strToU8 } from "fflate";

// 1. Splice a counterparty redline (one w:ins, one w:del) into word/header1.xml.
const files = unzipSync(new Uint8Array(fs.readFileSync(process.argv[2])));
const RPR = `<w:rPr><w:sz w:val="18"/></w:rPr>`;
const A = `w:author="Counterparty" w:date="2026-01-01T00:00:00Z"`;
files["word/header1.xml"] = strToU8(strFromU8(files["word/header1.xml"]).replace(/<w:p>[\s\S]*<\/w:p>/,
  `<w:p><w:ins w:id="9001" ${A}><w:r>${RPR}<w:t xml:space="preserve">Amended </w:t></w:r></w:ins>` +
  `<w:r>${RPR}<w:t xml:space="preserve">Services Agreement </w:t></w:r>` +
  `<w:del w:id="9002" ${A}><w:r>${RPR}<w:delText xml:space="preserve">DRAFT</w:delText></w:r></w:del></w:p>`));
const docx = Buffer.from(zipSync(Object.fromEntries(Object.entries(files).filter(([n, d]) => d.length || !n.endsWith("/")))));

const m = new Map();
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
  getItem: k => m.get(k) ?? null, setItem: (k, v) => void m.set(k, String(v)),
  removeItem: k => void m.delete(k), clear: () => m.clear(), key: i => [...m.keys()][i] ?? null, get length() { return m.size; } } });
const { Editor } = await import("@harbour-enterprises/superdoc/super-editor");
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const open = () => Editor.open(docx, { document: dom.window.document, documentMode: "suggesting",
  user: { name: "Repro", email: "r@example.com" }, telemetry: { enabled: false } });
const census = (xml) => `ins=${(xml.match(/<w:ins\b/g) ?? []).length} del=${(xml.match(/<w:del\b/g) ?? []).length}`;
const exportParts = async (ed) => { const r = await ed.exportDocx();
  return unzipSync(r instanceof Uint8Array ? r : new Uint8Array(await r.arrayBuffer())); };

// 2. Control: no header sub-editor registered.
const a = await open();
for (let i = 1; i <= 3; i++) { const p = await exportParts(a);
  console.log(`no header editor   export ${i}: header ${census(strFromU8(p["word/header1.xml"]))}  body ${census(strFromU8(p["word/document.xml"]))}`); }
a.destroy?.();

// 3. With a header sub-editor registered (the UI mounts one when a user clicks
//    into the header; the export loop reads only .editor from this entry).
const b = await open(); const sub = await open();
b.converter.headerEditors.push({ id: Object.keys(b.converter.headers)[0], editor: sub });
let last;
for (let i = 1; i <= 3; i++) { const p = await exportParts(b); last = strFromU8(p["word/header1.xml"]);
  console.log(`header editor open export ${i}: header ${census(last)}  body ${census(strFromU8(p["word/document.xml"]))}`); }
console.log("\nheader1.xml after export 3:\n" + last.match(/<w:p\b[\s\S]*?<\/w:p>/)[0]);
sub.destroy?.(); b.destroy?.();

// 4. The mutation, at the API level: exportToXmlJson edits the caller's object.
const c = await open(); const id = Object.keys(c.converter.headers)[0];
const n = () => (JSON.stringify(c.converter.headers[id]).match(/track(Insert|Delete)/g) ?? []).length;
console.log(`\nconverter.headers["${id}"] tracked marks before exportToXmlJson: ${n()}`);
c.converter.exportToXmlJson({ data: c.converter.headers[id], editor: c, editorSchema: c.schema,
  isHeaderFooter: true, comments: [], commentDefinitions: [] });
console.log(`converter.headers["${id}"] tracked marks after  exportToXmlJson: ${n()}`);
c.destroy?.();

Output (verbatim)

no header editor   export 1: header ins=1 del=1  body ins=5 del=3
no header editor   export 2: header ins=1 del=1  body ins=5 del=3
no header editor   export 3: header ins=1 del=1  body ins=5 del=3
header editor open export 1: header ins=1 del=1  body ins=5 del=3
header editor open export 2: header ins=0 del=0  body ins=5 del=3
header editor open export 3: header ins=0 del=0  body ins=5 del=3

header1.xml after export 3:
<w:p w14:paraId="00000001"><w:r><w:rPr><w:sz w:val="18" /></w:rPr><w:t xml:space="preserve">Amended </w:t></w:r><w:r><w:rPr><w:sz w:val="18" /></w:rPr><w:t xml:space="preserve">Services Agreement </w:t></w:r><w:r><w:rPr><w:sz w:val="18" /></w:rPr><w:t>DRAFT</w:t></w:r></w:p>
converter.headers["rId7"] tracked marks before exportToXmlJson: 2
converter.headers["rId7"] tracked marks after  exportToXmlJson: 0

DRAFT was inside a w:del in the input and is an ordinary <w:t> run in the export-3 output above.

Step 3 registers the sub-editor by pushing an entry onto converter.headerEditors directly, because mounting a real header editor needs the pagination UI and this repro is headless. That is the same entry the product creates — HeaderFooterEditorManager.#registerConverterEditor does converterEditors.push({ id: descriptor.id, editor }) at dist/chunks/src-B1wSp2Qc.es.js:119934 — and the export loop reads only .editor off it. Step 4 needs no stand-in at all: it shows exportToXmlJson dropping the caller's tracked marks from 2 to 0 using nothing but the shipped converter API.


SuperDoc version

1.46.1V1. (Filing against V1 explicitly since main is now V2 and V1 lives on the v1 branch. exportToXmlJson is V1-specific and may not exist in V2.)

Browser

None — headless Node 24 + jsdom. Nothing here depends on a browser.


Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions