fix(edit): refuse cut-off cell XML in edit_diagram operations (#960)

While an edit streams, the preview applies the operations received so
far. Chrome's DOMParser keeps a cell whose XML is cut off, so a vertex
without its geometry reached the canvas. draw.io placed it at the origin,
shifted the page layout and compensated the scroll position, and the
browser's clamping of that compensation left the diagram out of view
once the edit finished.

Check each new_xml with getXmlSyntaxError, as the whole document
already is, so an incomplete operation is reported instead of applied.
This commit is contained in:
Dayuan Jiang
2026-10-09 21:06:49 +09:00
committed by GitHub
parent 04d34534fd
commit 2418c47764
2 changed files with 77 additions and 0 deletions
@@ -216,6 +216,20 @@ export function applyDiagramOperations(
continue
}
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
@@ -268,6 +282,20 @@ export function applyDiagramOperations(
continue
}
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
@@ -149,3 +149,52 @@ describe("a wrapped mxCell with its wrapper's id", () => {
expect(result).toContain('label="B"')
})
})
describe("cut-off new_xml", () => {
// While edit_diagram streams, the last operation's XML is often
// incomplete. Chrome's DOMParser keeps the partial cell, so it must be
// refused before it reaches the diagram.
const CUT = `<mxCell id="b" value="B2" style="rounded=1;" vertex="1" parent="1">`
it("refuses an update whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{ operation: "update", cell_id: "b", new_xml: CUT },
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "update", cellId: "b" })
expect(errors[0].message).toContain("not well-formed")
expect(result).toContain('value="B"')
expect(result).not.toContain("B2")
})
it("refuses an add whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="1" y="2" width="3"`,
},
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "add", cellId: "n" })
expect(result).not.toContain('id="n"')
})
it("applies the complete operations before a cut-off one", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "update",
cell_id: "b",
new_xml: `<mxCell id="b" value="B3" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`,
},
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1">`,
},
])
expect(errors).toHaveLength(1)
expect(result).toContain('value="B3"')
expect(result).not.toContain('id="n"')
})
})