Filed as an API-hardening request, not a bug: MutationOptions.changeMode documents direct as its default, so what follows is the documented default doing its job. The ask is input validation, because the failure mode is silent and destructive.
The problem
blocks.delete(input, options) reads changeMode from options, the second argument. If a caller puts it in the first argument instead, the property is ignored, changeMode falls back to direct, and the block is permanently removed. The call returns { success: true } and logs nothing.
Destructive silence. Both calls below are made on the same document, same target, in documentMode: "suggesting":
changeMode in options (2nd argument)
returned : {"success":true}
tracked changes : 5 -> 6
block count : 111 -> 111
victim "MASTER PROFESSIONAL SERVICES AGREEMENT" still present: true
console output : (none)
changeMode in input (1st argument)
returned : {"success":true}
tracked changes : 5 -> 5
block count : 111 -> 110
victim "MASTER PROFESSIONAL SERVICES AGREEMENT" still present: false
console output : (none)
Same return value either way. One produces a reviewable tracked deletion; the other destroys the paragraph outright, with no tracked change, no warning, and nothing in the result to distinguish it.
Surface asymmetry. The two SuperDoc surfaces put changeMode on opposite sides of the call, so { target, changeMode } is the correct shape on one and a silent destructive edit on the other:
| Surface |
Signature |
Where changeMode goes |
In-process document API (@harbour-enterprises/superdoc 1.46.1) |
delete(input: BlocksDeleteInput, options?: MutationOptions) |
second argument |
SDK (@superdoc-dev/sdk 1.22.1) |
delete(params?: DocBlocksDeleteParams, options?: InvokeOptions) |
first argument, alongside target |
DocBlocksDeleteParams carries target and changeMode in one object (dist/generated/client.d.ts:1589, changeMode at :1595), and the SDK's second argument is transport options (:89384). Anyone moving code between the two surfaces, or building params from a JSON payload, lands on the wrong one naturally.
TypeScript's excess-property check catches the mistake only in the inline-literal form, not one variable away — the common shape when params are assembled dynamically:
editor.doc.blocks.delete({ target, changeMode: "tracked" }); // TS2353 — caught
const params = { target, changeMode: "tracked" };
editor.doc.blocks.delete(params); // no error — silently hard-deletes
(Verified with tsc 5.9.3, strict: true, against the package's shipped typings.) Plain-JavaScript and JSON-driven callers get no protection at all.
The ask
Reject — or at least warn on — unrecognized properties in the input object, rather than silently degrading to a destructive direct edit. changeMode and dryRun in an input object are unambiguous signals that the caller meant to reach MutationOptions.
The contract already knows which keys belong where, so one shared validator could cover every operation rather than one per adapter. dist/document-api/src/contract/operation-registry.d.ts:148 pairs input: BlocksDeleteInput with options: MutationOptions; BlocksDeleteInput is { target } and nothing else (dist/document-api/src/types/blocks.types.d.ts:81), while MutationOptions holds changeMode and dryRun (dist/document-api/src/write/write.d.ts:17).
Steps to reproduce
Test fixture: test_contract.docx — synthetic, no third-party content. The output above is this script's verbatim output.
npm i @harbour-enterprises/superdoc@1.46.1 jsdom
node repro.mjs test_contract.docx
repro.mjs:
import fs from "node:fs";
import { JSDOM } from "jsdom";
const store = new Map();
globalThis.localStorage = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => void store.set(k, String(v)),
removeItem: (k) => void store.delete(k),
clear: () => store.clear(),
};
const { Editor } = await import("@harbour-enterprises/superdoc/super-editor");
const bytes = fs.readFileSync(process.argv[2] ?? "test_contract.docx");
async function open() {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const editor = await Editor.open(Buffer.from(bytes), {
document: dom.window.document,
documentMode: "suggesting", // track changes on
user: { name: "Test User", email: "test@example.com" },
telemetry: { enabled: false },
});
return { editor, close: () => (editor.destroy?.(), dom.window.close()) };
}
// Delete the same paragraph twice, differing only in where changeMode is passed.
for (const where of ["options (2nd argument)", "input (1st argument)"]) {
const { editor, close } = await open();
const doc = editor.doc;
const before = doc.blocks.list();
const victim = before.blocks.find((b) => b.nodeType === "paragraph" && !b.isEmpty);
const target = { kind: "block", nodeType: "paragraph", nodeId: victim.nodeId };
const tcBefore = doc.trackChanges.list().total;
// Capture anything the call logs, to test the "silent" part of the claim.
const logged = [];
const real = { warn: console.warn, error: console.error, info: console.info };
for (const k of Object.keys(real)) console[k] = (...a) => logged.push(`${k}: ${a[0]}`);
const result =
where === "options (2nd argument)"
? doc.blocks.delete({ target }, { changeMode: "tracked" })
: doc.blocks.delete({ target, changeMode: "tracked" });
Object.assign(console, real);
const tcAfter = doc.trackChanges.list().total;
const after = doc.blocks.list();
const stillThere = after.blocks.some((b) => b.nodeId === victim.nodeId);
console.log(`changeMode in ${where}`);
console.log(` returned : ${JSON.stringify({ success: result.success })}`);
console.log(` tracked changes : ${tcBefore} -> ${tcAfter}`);
console.log(` block count : ${before.total} -> ${after.total}`);
console.log(` victim "${victim.textPreview}" still present: ${stillThere}`);
console.log(` console output : ${logged.length ? logged.join(" | ") : "(none)"}`);
console.log("");
close();
}
SuperDoc version
1.46.1 (V1) — the V1 line, now on the v1 branch. Not tested against V2 on main. SDK comparison is against @superdoc-dev/sdk@1.22.1 (the legacy dist-tag, the V1-compatible line).
Browser
None — headless Node 24 with jsdom. No browser involved.
Related
#3594 is a different problem — capability flags reporting unreliable trackability — but a maintainer there already characterized the neighbouring behaviour as "a bug in the Document API contract". This report is about an option that is never read at all, rather than a flag that is read and wrong; linking the two may help whoever picks up contract-level validation.
Filed as an API-hardening request, not a bug:
MutationOptions.changeModedocumentsdirectas its default, so what follows is the documented default doing its job. The ask is input validation, because the failure mode is silent and destructive.The problem
blocks.delete(input, options)readschangeModefromoptions, the second argument. If a caller puts it in the first argument instead, the property is ignored,changeModefalls back todirect, and the block is permanently removed. The call returns{ success: true }and logs nothing.Destructive silence. Both calls below are made on the same document, same target, in
documentMode: "suggesting":Same return value either way. One produces a reviewable tracked deletion; the other destroys the paragraph outright, with no tracked change, no warning, and nothing in the result to distinguish it.
Surface asymmetry. The two SuperDoc surfaces put
changeModeon opposite sides of the call, so{ target, changeMode }is the correct shape on one and a silent destructive edit on the other:changeModegoes@harbour-enterprises/superdoc1.46.1)delete(input: BlocksDeleteInput, options?: MutationOptions)@superdoc-dev/sdk1.22.1)delete(params?: DocBlocksDeleteParams, options?: InvokeOptions)targetDocBlocksDeleteParamscarriestargetandchangeModein one object (dist/generated/client.d.ts:1589,changeModeat:1595), and the SDK's second argument is transport options (:89384). Anyone moving code between the two surfaces, or building params from a JSON payload, lands on the wrong one naturally.TypeScript's excess-property check catches the mistake only in the inline-literal form, not one variable away — the common shape when params are assembled dynamically:
(Verified with
tsc5.9.3,strict: true, against the package's shipped typings.) Plain-JavaScript and JSON-driven callers get no protection at all.The ask
Reject — or at least warn on — unrecognized properties in the input object, rather than silently degrading to a destructive direct edit.
changeModeanddryRunin an input object are unambiguous signals that the caller meant to reachMutationOptions.The contract already knows which keys belong where, so one shared validator could cover every operation rather than one per adapter.
dist/document-api/src/contract/operation-registry.d.ts:148pairsinput: BlocksDeleteInputwithoptions: MutationOptions;BlocksDeleteInputis{ target }and nothing else (dist/document-api/src/types/blocks.types.d.ts:81), whileMutationOptionsholdschangeModeanddryRun(dist/document-api/src/write/write.d.ts:17).Steps to reproduce
Test fixture:
test_contract.docx— synthetic, no third-party content. The output above is this script's verbatim output.repro.mjs:SuperDoc version
1.46.1 (V1)— the V1 line, now on thev1branch. Not tested against V2 onmain. SDK comparison is against@superdoc-dev/sdk@1.22.1(thelegacydist-tag, the V1-compatible line).Browser
None — headless Node 24 with jsdom. No browser involved.
Related
#3594 is a different problem — capability flags reporting unreliable trackability — but a maintainer there already characterized the neighbouring behaviour as "a bug in the Document API contract". This report is about an option that is never read at all, rather than a flag that is read and wrong; linking the two may help whoever picks up contract-level validation.