From 2177e1cfa047874e5d327be234f7c944e1897444 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 15 Apr 2026 14:20:00 +1000 Subject: [PATCH] test(transaction): add deferred flush and conflict-scope coverage --- .../element/tests/transactionLedger.test.ts | 54 +++++++++++++++ .../tests/transactionManager.test.tsx | 69 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/packages/element/tests/transactionLedger.test.ts b/packages/element/tests/transactionLedger.test.ts index ae4b3d48fc..b280cddeca 100644 --- a/packages/element/tests/transactionLedger.test.ts +++ b/packages/element/tests/transactionLedger.test.ts @@ -122,4 +122,58 @@ describe("TransactionLedger", () => { expect(logicalBefore.get(live.id)?.strokeColor).toBe("#000000"); expect(logicalAfter.get(live.id)?.strokeColor).toBe("#ff006e"); }); + + it("applies per-prop conflict handling and supports element-scope skip", () => { + const ledger = new TransactionLedger(); + const baseline = API.createElement({ + type: "rectangle", + id: "rect-1", + strokeColor: "#000000", + backgroundColor: "#ffffff", + }); + const target = { + ...baseline, + strokeColor: "#ff006e", + backgroundColor: "#ffd8a8", + version: baseline.version + 1, + }; + const live = { + ...target, + strokeColor: "#3a86ff", + backgroundColor: "#ffd8a8", + version: target.version + 1, + }; + + ledger.recordStep(arrayToMap([baseline]), arrayToMap([target])); + + const propScope = ledger.buildSyntheticSnapshots( + arrayToMap([live]), + DEFAULT_TRANSACTION_MERGE_POLICY, + ); + expect(propScope.logicalBefore.get(live.id)?.strokeColor).toBe("#3a86ff"); + expect(propScope.logicalAfter.get(live.id)?.strokeColor).toBe("#3a86ff"); + expect(propScope.logicalBefore.get(live.id)?.backgroundColor).toBe( + "#ffffff", + ); + expect(propScope.logicalAfter.get(live.id)?.backgroundColor).toBe( + "#ffd8a8", + ); + + const elementScope = ledger.buildSyntheticSnapshots(arrayToMap([live]), { + ...DEFAULT_TRANSACTION_MERGE_POLICY, + conflictScope: "element", + }); + expect(elementScope.logicalBefore.get(live.id)?.strokeColor).toBe( + "#3a86ff", + ); + expect(elementScope.logicalAfter.get(live.id)?.strokeColor).toBe( + "#3a86ff", + ); + expect(elementScope.logicalBefore.get(live.id)?.backgroundColor).toBe( + "#ffd8a8", + ); + expect(elementScope.logicalAfter.get(live.id)?.backgroundColor).toBe( + "#ffd8a8", + ); + }); }); diff --git a/packages/excalidraw/tests/transactionManager.test.tsx b/packages/excalidraw/tests/transactionManager.test.tsx index 3268eb6a91..5f4f8d71af 100644 --- a/packages/excalidraw/tests/transactionManager.test.tsx +++ b/packages/excalidraw/tests/transactionManager.test.tsx @@ -291,6 +291,75 @@ describe("TransactionManager", () => { }); }); + it("defers isolated transaction commit until store is idle", async () => { + const transactionElement = API.createElement({ + type: "rectangle", + id: "tx-idle", + x: 0, + y: 0, + }); + const userElement = API.createElement({ + type: "rectangle", + id: "user-idle", + x: 300, + y: 0, + }); + const store = h.store as { + scheduleAction: ( + action: (typeof CaptureUpdateAction)[keyof typeof CaptureUpdateAction], + ) => void; + hasPendingIsolatedIncrements: () => boolean; + }; + + setSceneBaseline([transactionElement, userElement]); + expect(API.getUndoStack().length).toBe(0); + + const session = h.app.transactionManager.open(); + await session.apply(async () => { + applyElementUpdate(transactionElement.id, { x: 180 }, "NEVER"); + }); + + // Keep store non-idle so isolated increment cannot flush immediately. + act(() => { + store.scheduleAction(CaptureUpdateAction.EVENTUALLY); + }); + + const summary = commitSession(session); + expect(summary.historyCommitted).toBe(true); + expect(store.hasPendingIsolatedIncrements()).toBe(true); + expect(API.getUndoStack().length).toBe(0); + + applyElementUpdate(userElement.id, { y: 220 }, "IMMEDIATELY"); + + await waitFor(() => { + expect(store.hasPendingIsolatedIncrements()).toBe(false); + expect(API.getUndoStack().length).toBe(2); + }); + + let liveTxElement = getElement(transactionElement.id)!; + let liveUserElement = getElement(userElement.id)!; + expect(liveTxElement.x).toBe(180); + expect(liveUserElement.y).toBe(220); + + // Latest entry should still be the isolated transaction increment. + act(() => { + Keyboard.undo(); + }); + await waitFor(() => { + liveTxElement = getElement(transactionElement.id)!; + expect(liveTxElement.x).toBe(transactionElement.x); + expect(getElement(userElement.id)?.y).toBe(220); + }); + + act(() => { + Keyboard.undo(); + }); + await waitFor(() => { + liveUserElement = getElement(userElement.id)!; + expect(liveUserElement.y).toBe(userElement.y); + }); + }); + it("undoes transaction-created elements without rolling back user history entries", async () => { const base = API.createElement({ type: "rectangle",