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.1 — V1. (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
What happened?
Once a header (or footer) sub-editor exists, the first
exportDocx()writes the header's importedw:ins/w:delcorrectly, 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 aw:delcomes 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 haveins=0 del=0, and the deleted wordDRAFTis written as a plain<w:t>DRAFT</w:t>. Body tracked changes are unaffected throughout (ins=5 del=3on 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
exportToXmlJsonmutates 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:14306and:14373:For the body this is harmless, because the body export starts from a fresh
editor.getUpdatedJson()snapshot on every call.#exportProcessHeadersFootersinstead passes the persistent import-time tree by reference (:89416–:89424):So export 1 strips the marks off
this.headers[id]permanently, and export 2 re-serializes an already-stripped tree. Theif (!headerEditor) return;is why the no-sub-editor arm stays stable. AcarbonCopy/structured clone ofdata— 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 intoword/header1.xmlso the fixture has an imported header redline to lose.Output (verbatim)
DRAFTwas inside aw:delin 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.headerEditorsdirectly, because mounting a real header editor needs the pagination UI and this repro is headless. That is the same entry the product creates —HeaderFooterEditorManager.#registerConverterEditordoesconverterEditors.push({ id: descriptor.id, editor })atdist/chunks/src-B1wSp2Qc.es.js:119934— and the export loop reads only.editoroff it. Step 4 needs no stand-in at all: it showsexportToXmlJsondropping the caller's tracked marks from 2 to 0 using nothing but the shippedconverterAPI.SuperDoc version
1.46.1— V1. (Filing against V1 explicitly sincemainis now V2 and V1 lives on thev1branch.exportToXmlJsonis V1-specific and may not exist in V2.)Browser
None — headless Node 24 + jsdom. Nothing here depends on a browser.
Related
w:rFonts). Same code path, separate defect.