fix: even deltas with version & version nonce are valid (#9897)

This commit is contained in:
Marcel Mraz
2025-08-21 16:09:19 +02:00
committed by GitHub
parent c78e4aab7f
commit b5ad7ae4e3
5 changed files with 415 additions and 87 deletions

View File

@@ -1111,16 +1111,16 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => }: Delta<ElementPartial>) =>
!!( !!(
deleted.version &&
inserted.version &&
// versions are required integers // versions are required integers
(
Number.isInteger(deleted.version) && Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) && Number.isInteger(inserted.version) &&
// versions should be positive, zero included // versions should be positive, zero included
deleted.version >= 0 && deleted.version! >= 0 &&
inserted.version >= 0 && inserted.version! >= 0 &&
// versions should never be the same // versions should never be the same
deleted.version !== inserted.version deleted.version !== inserted.version
)
); );
private static satisfiesUniqueInvariants = ( private static satisfiesUniqueInvariants = (
@@ -1191,9 +1191,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) { if (!prevElement.isDeleted) {
removed[prevElement.id] = delta; removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
} }
} }
} }
@@ -1221,6 +1222,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
// ignore updates which would "delete" already deleted element // ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) { if (!nextElement.isDeleted) {
added[nextElement.id] = delta; added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
} }
continue; continue;
@@ -1250,17 +1253,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
const strippedInserted = ElementsDelta.stripVersionProps(
delta.inserted,
);
// making sure there are at least some changes and only changed version & versionNonce does not count!
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
updated[nextElement.id] = delta; updated[nextElement.id] = delta;
} }
} }
}
return ElementsDelta.create(added, removed, updated); return ElementsDelta.create(added, removed, updated);
} }
@@ -1372,15 +1367,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
latestDelta = delta; latestDelta = delta;
} }
const strippedDeleted = ElementsDelta.stripVersionProps(
latestDelta.deleted,
);
const strippedInserted = ElementsDelta.stripVersionProps(
latestDelta.inserted,
);
// it might happen that after applying latest changes the delta itself does not contain any changes // it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) { if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
modifiedDeltas[id] = latestDelta; modifiedDeltas[id] = latestDelta;
} }
} }
@@ -2075,12 +2063,4 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return strippedPartial; return strippedPartial;
} }
private static stripVersionProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
} }

View File

@@ -8,7 +8,7 @@ import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => { describe("ElementsDelta", () => {
describe("elements delta calculation", () => { describe("elements delta calculation", () => {
it("should not create removed delta when element gets removed but was already deleted", () => { it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({ const element = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@@ -19,12 +19,12 @@ describe("ElementsDelta", () => {
const prevElements = new Map([[element.id, element]]); const prevElements = new Map([[element.id, element]]);
const nextElements = new Map(); const nextElements = new Map();
const delta = ElementsDelta.calculate(prevElements, nextElements); expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
expect(delta.isEmpty()).toBeTruthy(); ).not.toThrow();
}); });
it("should not create added delta when adding element as already deleted", () => { it("should not throw when adding element as already deleted", () => {
const element = API.createElement({ const element = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@@ -35,12 +35,12 @@ describe("ElementsDelta", () => {
const prevElements = new Map(); const prevElements = new Map();
const nextElements = new Map([[element.id, element]]); const nextElements = new Map([[element.id, element]]);
const delta = ElementsDelta.calculate(prevElements, nextElements); expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
expect(delta.isEmpty()).toBeTruthy(); ).not.toThrow();
}); });
it("should not create updated delta when there is only version and versionNonce change", () => { it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({ const baseElement = API.createElement({
type: "rectangle", type: "rectangle",
x: 100, x: 100,
@@ -65,7 +65,24 @@ describe("ElementsDelta", () => {
nextElements as SceneElementsMap, nextElements as SceneElementsMap,
); );
expect(delta.isEmpty()).toBeTruthy(); expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
}); });
}); });

View File

@@ -282,6 +282,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {
"id0": {
"deleted": {
"version": 12,
},
"inserted": {
"version": 11,
},
},
"id1": { "id1": {
"deleted": { "deleted": {
"boundElements": [], "boundElements": [],
@@ -396,6 +404,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"version": 12, "version": 12,
}, },
}, },
"id15": {
"deleted": {
"version": 10,
},
"inserted": {
"version": 9,
},
},
"id4": { "id4": {
"deleted": { "deleted": {
"height": "99.19972", "height": "99.19972",
@@ -837,6 +853,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {
"id0": {
"deleted": {
"version": 13,
},
"inserted": {
"version": 12,
},
},
"id1": { "id1": {
"deleted": { "deleted": {
"boundElements": [], "boundElements": [],
@@ -2632,7 +2656,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"height": 100, "height": 100,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": true, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -2681,7 +2705,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
"updated": 1, "updated": 1,
"version": 6, "version": 8,
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 15, "x": 15,
@@ -2695,7 +2719,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": "id0",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -2742,10 +2766,12 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
}, },
}, },
"elements": { "elements": {
"added": { "added": {},
"removed": {},
"updated": {
"id0": { "id0": {
"deleted": { "deleted": {
"isDeleted": true, "isDeleted": false,
"version": 9, "version": 9,
}, },
"inserted": { "inserted": {
@@ -2774,16 +2800,21 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"y": 10, "y": 10,
}, },
}, },
}, "id1": {
"removed": {},
"updated": {
"id5": {
"deleted": { "deleted": {
"containerId": null,
"version": 8,
},
"inserted": {
"containerId": null, "containerId": null,
"version": 7, "version": 7,
}, },
},
"id5": {
"deleted": {
"version": 7,
},
"inserted": { "inserted": {
"containerId": "id0",
"version": 6, "version": 6,
}, },
}, },
@@ -3096,6 +3127,14 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"version": 8, "version": 8,
}, },
}, },
"id5": {
"deleted": {
"version": 7,
},
"inserted": {
"version": 6,
},
},
}, },
}, },
"id": "id9", "id": "id9",
@@ -4645,15 +4684,15 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"id1": { "id1": {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"version": 4, "version": 8,
"x": 15, "x": 15,
"y": 15, "y": 15,
}, },
"inserted": { "inserted": {
"angle": 90, "angle": 0,
"version": 3, "version": 7,
"x": 205, "x": 15,
"y": 205, "y": 15,
}, },
}, },
}, },
@@ -5632,12 +5671,12 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"updated": { "updated": {
"id1": { "id1": {
"deleted": { "deleted": {
"frameId": "id0", "frameId": null,
"version": 5, "version": 9,
}, },
"inserted": { "inserted": {
"frameId": null, "frameId": null,
"version": 6, "version": 8,
}, },
}, },
}, },
@@ -5784,7 +5823,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 6,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -5816,7 +5855,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 5,
"width": 100, "width": 100,
"x": 100, "x": 100,
"y": 100, "y": 100,
@@ -5852,7 +5891,74 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"A",
],
"height": 100,
"index": "a0",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 5,
"width": 100,
"x": 0,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 4,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"A",
],
"height": 100,
"index": "a1",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 5,
"width": 100,
"x": 100,
"y": 100,
},
"inserted": {
"isDeleted": true,
"version": 4,
},
},
},
}, },
"id": "id13", "id": "id13",
}, },
@@ -6072,7 +6178,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 8, "version": 9,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": 0,
@@ -6102,7 +6208,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 8, "version": 9,
"width": 10, "width": 10,
"x": 50, "x": 50,
"y": 50, "y": 50,
@@ -6187,7 +6293,39 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id3": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a1",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 10,
"x": 20,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 7,
},
},
},
}, },
"id": "id18", "id": "id18",
}, },
@@ -6205,11 +6343,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id3": { "id3": {
"deleted": { "deleted": {
"backgroundColor": "#ffc9c9", "backgroundColor": "#ffc9c9",
"version": 8, "version": 9,
}, },
"inserted": { "inserted": {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"version": 7, "version": 8,
}, },
}, },
}, },
@@ -6234,7 +6372,39 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id8": {
"deleted": {
"angle": 0,
"backgroundColor": "#ffc9c9",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a2",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 10,
"x": 30,
"y": 30,
},
"inserted": {
"isDeleted": true,
"version": 7,
},
},
},
}, },
"id": "id20", "id": "id20",
}, },
@@ -6251,12 +6421,12 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"updated": { "updated": {
"id8": { "id8": {
"deleted": { "deleted": {
"version": 8, "version": 9,
"x": 50, "x": 50,
"y": 50, "y": 50,
}, },
"inserted": { "inserted": {
"version": 7, "version": 8,
"x": 30, "x": 30,
"y": 30, "y": 30,
}, },
@@ -7104,7 +7274,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 9,
"width": 10, "width": 10,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -7135,7 +7305,60 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a0",
"isDeleted": true,
"lastCommittedPoint": [
10,
10,
],
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
10,
10,
],
],
"roughness": 1,
"roundness": {
"type": 2,
},
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"version": 9,
"width": 10,
"x": 0,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 8,
},
},
},
}, },
"id": "id13", "id": "id13",
}, },
@@ -7344,7 +7567,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 8, "version": 9,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -7375,7 +7598,39 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id0": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"index": "a0",
"isDeleted": true,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 10,
"x": 10,
"y": 0,
},
"inserted": {
"isDeleted": true,
"version": 7,
},
},
},
}, },
"id": "id7", "id": "id7",
}, },
@@ -7393,11 +7648,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id0": { "id0": {
"deleted": { "deleted": {
"backgroundColor": "#ffec99", "backgroundColor": "#ffec99",
"version": 8, "version": 9,
}, },
"inserted": { "inserted": {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"version": 7, "version": 8,
}, },
}, },
}, },
@@ -10326,7 +10581,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 8, "version": 9,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": 0,
@@ -10409,7 +10664,18 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"elements": { "elements": {
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": {}, "updated": {
"id0": {
"deleted": {
"isDeleted": false,
"version": 9,
},
"inserted": {
"isDeleted": false,
"version": 8,
},
},
},
}, },
"id": "id8", "id": "id8",
}, },
@@ -15775,6 +16041,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 5, "version": 5,
}, },
}, },
"id1": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 4,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@@ -16736,6 +17010,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 5, "version": 5,
}, },
}, },
"id1": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 5,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@@ -17361,6 +17643,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 9, "version": 9,
}, },
}, },
"id1": {
"deleted": {
"version": 10,
},
"inserted": {
"version": 9,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@@ -17722,6 +18012,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 7, "version": 7,
}, },
}, },
"id2": {
"deleted": {
"version": 4,
},
"inserted": {
"version": 3,
},
},
}, },
}, },
"id": "id21", "id": "id21",

View File

@@ -2216,7 +2216,16 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
}, },
}, },
}, },
"updated": {}, "updated": {
"id0": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
},
}, },
"id": "id6", "id": "id6",
}, },
@@ -10892,7 +10901,32 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
}, },
}, },
}, },
"updated": {}, "updated": {
"id0": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id3": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id6": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
},
}, },
"id": "id21", "id": "id21",
}, },

View File

@@ -4055,7 +4055,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }], boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: true, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
id: text.id, id: text.id,
@@ -4064,8 +4064,7 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: remoteText.id, id: remoteText.id,
// unbound containerId: container.id,
containerId: null,
isDeleted: false, isDeleted: false,
}), }),
]); ]);