diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 216921bace..77d1330167 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -562,6 +562,89 @@ export class EphemeralIncrement extends StoreIncrement { } } +/** + * Serializable semantics marker used to recover pre-tx undo baselines + * without rewriting history stack entries. + */ +export type TxUndoOverride = { + txId: string; + elementId: string; + prop: string; + expectedInsertedValue: unknown; + preTxBaselineValue: unknown; + consumedKey: string; +}; + +export type StoreDeltaSemantics = { + txUndoOverrides?: TxUndoOverride[]; +}; + +const cloneTxUndoOverride = (override: TxUndoOverride): TxUndoOverride => ({ + ...override, +}); + +const cloneStoreDeltaSemantics = ( + semantics: StoreDeltaSemantics | undefined, +): StoreDeltaSemantics | undefined => + semantics + ? { + ...semantics, + txUndoOverrides: semantics.txUndoOverrides?.map(cloneTxUndoOverride), + } + : undefined; + +const pruneStoreDeltaSemantics = (delta: StoreDelta) => { + const txUndoOverrides = delta.semantics?.txUndoOverrides; + if (!txUndoOverrides || txUndoOverrides.length === 0) { + return; + } + + const updatedEntries = delta.elements.updated as Record< + string, + Delta> + >; + const nextTxUndoOverrides = txUndoOverrides.filter((override) => { + const updatedEntry = updatedEntries[override.elementId]; + return ( + !!updatedEntry && + Object.prototype.hasOwnProperty.call(updatedEntry.inserted, override.prop) + ); + }); + + if (nextTxUndoOverrides.length === txUndoOverrides.length) { + return; + } + + if (nextTxUndoOverrides.length === 0) { + delta.semantics = undefined; + return; + } + + delta.semantics = { + ...delta.semantics, + txUndoOverrides: nextTxUndoOverrides.map(cloneTxUndoOverride), + }; +}; + +export const mergeStoreDeltaSemantics = ( + delta: StoreDelta, + semantics: StoreDeltaSemantics, +) => { + const txUndoOverrides = semantics.txUndoOverrides ?? []; + if (txUndoOverrides.length === 0) { + return; + } + + const existing = delta.semantics?.txUndoOverrides ?? []; + delta.semantics = { + ...delta.semantics, + txUndoOverrides: [ + ...existing.map(cloneTxUndoOverride), + ...txUndoOverrides.map(cloneTxUndoOverride), + ], + }; +}; + /** * Represents a captured delta by the Store. */ @@ -570,6 +653,7 @@ export class StoreDelta { public readonly id: string, public readonly elements: ElementsDelta, public readonly appState: AppStateDelta, + public semantics?: StoreDeltaSemantics, ) {} /** @@ -579,12 +663,16 @@ export class StoreDelta { elements: ElementsDelta, appState: AppStateDelta, opts: { - id: string; - } = { - id: randomId(), - }, + id?: string; + semantics?: StoreDeltaSemantics; + } = {}, ) { - return new this(opts.id, elements, appState); + return new this( + opts.id ?? randomId(), + elements, + appState, + cloneStoreDeltaSemantics(opts.semantics), + ); } /** @@ -609,11 +697,12 @@ export class StoreDelta { * Restore a store delta instance from a DTO. */ public static restore(storeDeltaDTO: DTO) { - const { id, elements, appState } = storeDeltaDTO; + const { id, elements, appState, semantics } = storeDeltaDTO; return new this( id, ElementsDelta.restore(elements), AppStateDelta.restore(appState), + cloneStoreDeltaSemantics(semantics), ); } @@ -624,11 +713,17 @@ export class StoreDelta { id, elements: { added, removed, updated }, appState: { delta: appStateDelta }, + semantics, }: DTO) { const elements = ElementsDelta.create(added, removed, updated); const appState = AppStateDelta.create(appStateDelta); - return new this(id, elements, appState); + return new this( + id, + elements, + appState, + cloneStoreDeltaSemantics(semantics), + ); } /** @@ -649,7 +744,12 @@ export class StoreDelta { * Inverse store delta, creates new instance of `StoreDelta`. */ public static inverse(delta: StoreDelta) { - return this.create(delta.elements.inverse(), delta.appState.inverse()); + const inversed = this.create( + delta.elements.inverse(), + delta.appState.inverse(), + { semantics: delta.semantics }, + ); + return inversed; } /** @@ -685,7 +785,7 @@ export class StoreDelta { nextElements: SceneElementsMap, modifierOptions?: "deleted" | "inserted", ): StoreDelta { - return this.create( + const nextDelta = this.create( delta.elements.applyLatestChanges( prevElements, nextElements, @@ -694,8 +794,11 @@ export class StoreDelta { delta.appState, { id: delta.id, + semantics: delta.semantics, }, ); + pruneStoreDeltaSemantics(nextDelta); + return nextDelta; } public static empty() { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c6d70f1891..5df765f45e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -835,6 +835,7 @@ class App extends React.Component { this.store = new Store(this); this.history = new History(this.store); this.transactionManager = new TransactionManager(this); + this.transactionManager.attachHistory(this.history); this.excalidrawContainerValue = { container: this.excalidrawContainerRef.current, @@ -842,7 +843,6 @@ class App extends React.Component { }; this.fonts = new Fonts(this.scene); - this.history = new History(this.store); this.actionManager.registerAll(actions); this.actionManager.registerAction(createUndoAction(this.history)); diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index 482065be45..4728613a23 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -87,10 +87,22 @@ export class HistoryChangedEvent { ) {} } +export type HistoryBeforeRecordListener = (delta: StoreDelta) => void; +export type HistoryPerformDirection = "undo" | "redo"; +export type HistoryEffectiveDeltaResolverContext = { + direction: HistoryPerformDirection; +}; +export type HistoryEffectiveDeltaResolver = ( + delta: HistoryDelta, + context: HistoryEffectiveDeltaResolverContext, +) => HistoryDelta; + export class History { public readonly onHistoryChangedEmitter = new Emitter< [HistoryChangedEvent] >(); + private readonly onBeforeRecordEmitter = new Emitter<[StoreDelta]>(); + private effectiveDeltaResolver: HistoryEffectiveDeltaResolver | null = null; public readonly undoStack: HistoryDelta[] = []; public readonly redoStack: HistoryDelta[] = []; @@ -105,6 +117,20 @@ export class History { constructor(private readonly store: Store) {} + /** + * Registers a hook that runs before a durable delta is converted + * into a history entry. + */ + public onBeforeRecord(callback: HistoryBeforeRecordListener) { + return this.onBeforeRecordEmitter.on(callback); + } + + public setEffectiveDeltaResolver( + resolver: HistoryEffectiveDeltaResolver | null, + ) { + this.effectiveDeltaResolver = resolver; + } + public clear() { this.undoStack.length = 0; this.redoStack.length = 0; @@ -119,6 +145,8 @@ export class History { return; } + this.onBeforeRecordEmitter.trigger(delta); + // construct history entry, so once it's emitted, it's not recorded again const historyDelta = HistoryDelta.inverse(delta); @@ -131,15 +159,14 @@ export class History { this.redoStack.length = 0; } - this.onHistoryChangedEmitter.trigger( - new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), - ); + this.emitHistoryChanged(); } public undo(elements: SceneElementsMap, appState: AppState) { return this.perform( elements, appState, + "undo", () => History.pop(this.undoStack), (entry: HistoryDelta) => History.push(this.redoStack, entry), ); @@ -149,6 +176,7 @@ export class History { return this.perform( elements, appState, + "redo", () => History.pop(this.redoStack), (entry: HistoryDelta) => History.push(this.undoStack, entry), ); @@ -157,6 +185,7 @@ export class History { private perform( elements: SceneElementsMap, appState: AppState, + direction: HistoryPerformDirection, pop: () => HistoryDelta | null, push: (entry: HistoryDelta) => void, ): [SceneElementsMap, AppState] | void { @@ -177,9 +206,15 @@ export class History { // iterate through the history entries in case they result in no visible changes while (historyDelta) { + // Roundtrip invariant: whichever delta we execute must be the one that + // continues through applyLatestChanges -> inverse -> opposite stack. + let entryToPush = historyDelta; try { + const effectiveDelta = this.resolveEffectiveDelta(historyDelta, { + direction, + }); [nextElements, nextAppState, containsVisibleChange] = - historyDelta.applyTo(nextElements, nextAppState, prevSnapshot); + effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot); const prevElements = prevSnapshot.elements; const nextSnapshot = prevSnapshot.maybeClone( @@ -190,7 +225,7 @@ export class History { const change = StoreChange.create(prevSnapshot, nextSnapshot); const delta = HistoryDelta.applyLatestChanges( - historyDelta, + effectiveDelta, prevElements, nextElements, ); @@ -203,12 +238,12 @@ export class History { delta, }); - historyDelta = delta; + entryToPush = delta; } prevSnapshot = nextSnapshot; } finally { - push(historyDelta); + push(entryToPush); } if (containsVisibleChange) { @@ -222,12 +257,16 @@ export class History { } finally { // trigger the history change event before returning completely // also trigger it just once, no need doing so on each entry - this.onHistoryChangedEmitter.trigger( - new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), - ); + this.emitHistoryChanged(); } } + private emitHistoryChanged() { + this.onHistoryChangedEmitter.trigger( + new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), + ); + } + private static pop(stack: HistoryDelta[]): HistoryDelta | null { if (!stack.length) { return null; @@ -246,4 +285,15 @@ export class History { const inversedEntry = HistoryDelta.inverse(entry); return stack.push(inversedEntry); } + + private resolveEffectiveDelta( + delta: HistoryDelta, + context: HistoryEffectiveDeltaResolverContext, + ): HistoryDelta { + if (!this.effectiveDeltaResolver) { + return delta; + } + + return this.effectiveDeltaResolver(delta, context); + } } diff --git a/packages/excalidraw/tests/transaction.test.tsx b/packages/excalidraw/tests/transaction.test.tsx index 5729540fdf..8fe0a29bbf 100644 --- a/packages/excalidraw/tests/transaction.test.tsx +++ b/packages/excalidraw/tests/transaction.test.tsx @@ -41,6 +41,23 @@ const applyElementUpdate = ( }); }; +const applyElementUpdatesInSingleAction = ( + updatesById: Record>, + captureUpdate: keyof typeof CaptureUpdateAction, +) => { + const nextElements = h.app.scene + .getElementsIncludingDeleted() + .map((element) => { + const updates = updatesById[element.id]; + return updates ? newElementWith(element, updates) : element; + }); + + API.updateScene({ + elements: nextElements, + captureUpdate: CaptureUpdateAction[captureUpdate], + }); +}; + const setSceneBaseline = (elements: readonly ExcalidrawElement[]) => { API.updateScene({ elements, @@ -788,15 +805,532 @@ describe("createTransaction live-wins-per-prop behavior", () => { expect(live.backgroundColor).toBe("#fff"); }); - it("undoing regular edit after tx rollback restores pre-edit live value", () => { + it("undoing regular edit after tx rollback restores pre-tx baseline value", () => { const element = setupSamePropertyConflictScenario(); Keyboard.undo(); Keyboard.undo(); const live = getElement(element.id)!; - expect(live.strokeColor).toBe("#f00"); + // strokeColor should be #000 (pre-tx baseline), not #f00 (tx intermediate). + // The commit-time patching replaces the tx intermediate in the user's + // undo entry with the pre-tx baseline. + expect(live.strokeColor).toBe("#000"); expect(live.x).toBe(0); expect(live.backgroundColor).toBe("#fff"); }); }); + +// --------------------------------------------------------------------------- +// Transaction undo semantics markers + effective delta resolution +// --------------------------------------------------------------------------- + +describe("transaction undo semantics markers", () => { + beforeEach(async () => { + await render(); + }); + + afterEach(() => { + unmountComponent(); + }); + + it("keeps pre-commit undo action-local (override is inactive while tx is active)", () => { + const element = API.createElement({ + type: "rectangle", + id: "redo-patch", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + + // tx sets strokeColor to purple + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080", x: 200 }) + : el, + ), + }); + }); + + // User overrides strokeColor to red + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + + // Undo before tx is ended should still restore tx intermediate (purple). + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#800080"); + + // Ending the tx later should not rewrite the already-undone user entry. + commitTransaction(tx); + + // strokeColor should stay purple (tx committed value, user undid their change) + expect(getElement(element.id)!.strokeColor).toBe("#800080"); + expect(getElement(element.id)!.x).toBe(200); + }); + + it("handles multiple user actions during tx correctly", () => { + const element = API.createElement({ + type: "rectangle", + id: "multi-action", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + + // tx sets strokeColor to purple + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080", x: 200 }) + : el, + ), + }); + }); + + // User action 1: change strokeColor to red + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + // User action 2: change strokeColor to green + applyElementUpdate(element.id, { strokeColor: "#0f0" }, "IMMEDIATELY"); + + commitTransaction(tx); + + // Undo tx entry + Keyboard.undo(); + expect(getElement(element.id)!.x).toBe(0); + + // Undo user action 2 (green → red) + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + // Undo user action 1 (red → black, NOT purple) + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + }); + + it("preserves undo/redo roundtrip after applying ended-tx override", () => { + const element = API.createElement({ + type: "rectangle", + id: "roundtrip-after-override", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080", x: 200 }) + : el, + ), + }); + }); + + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + commitTransaction(tx); + + // Undo tx entry + Keyboard.undo(); + expect(getElement(element.id)!.x).toBe(0); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + // Undo user entry (with override) + Keyboard.undo(); + expect(getElement(element.id)!.x).toBe(0); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + + // Redo user then redo tx should return to committed live state. + Keyboard.redo(); + expect(getElement(element.id)!.x).toBe(0); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + Keyboard.redo(); + expect(getElement(element.id)!.x).toBe(200); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + }); + + it("does not inject override when user modifies a different property", () => { + const element = API.createElement({ + type: "rectangle", + id: "diff-prop", + strokeColor: "#000", + backgroundColor: "#fff", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + + // tx only changes x + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id ? newElementWith(el, { x: 200 }) : el, + ), + }); + }); + + // User changes strokeColor (different property from tx) + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + + commitTransaction(tx); + + // Undo tx + Keyboard.undo(); + expect(getElement(element.id)!.x).toBe(0); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + // Undo user change — should restore to #000 (was never a tx intermediate) + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + }); + + it("does not inject override for user entry on tx-created element", () => { + // Start with an empty scene + setSceneBaseline([]); + + const tx = h.app.createTransaction(); + const created = API.createElement({ + type: "rectangle", + id: "tx-created", + strokeColor: "#800080", + x: 100, + }); + + // tx creates the element + act(() => { + tx.updateScene({ + elements: [...h.app.scene.getElementsIncludingDeleted(), created], + }); + }); + + // User modifies the tx-created element + applyElementUpdate("tx-created", { strokeColor: "#f00" }, "IMMEDIATELY"); + + commitTransaction(tx); + + // The user's undo entry has inserted.strokeColor = #800080 (tx value). + // Since baselineElement is null for a tx-created element, no override + // marker is added — #800080 is correct to restore to here. + Keyboard.undo(); + expect(getElement("tx-created")!.strokeColor).toBe("#800080"); + }); + + it("does not over-override later user action that intentionally returns to tx intermediate", () => { + const element = API.createElement({ + type: "rectangle", + id: "intentional-intermediate", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080", x: 200 }) + : el, + ), + }); + }); + + // polluted entry: purple -> red (should override to black on undo baseline) + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + // intentional action: red -> purple (should keep red as undo baseline) + applyElementUpdate(element.id, { strokeColor: "#800080" }, "IMMEDIATELY"); + + commitTransaction(tx); + + // undo tx synthetic entry first + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + expect(getElement(element.id)!.x).toBe(0); + + // undo intentional user action: should go back to red, not baseline black + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + // undo first polluted user action: should go back to pre-tx black + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + }); + + it("overrides only polluted props when one user entry updates multiple props", () => { + const element = API.createElement({ + type: "rectangle", + id: "single-entry-multi-prop", + strokeColor: "#000", + x: 0, + y: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080" }) + : el, + ), + }); + }); + + // One user history entry modifies both a polluted prop (strokeColor) + // and a non-polluted prop (x). + applyElementUpdatesInSingleAction( + { + [element.id]: { + strokeColor: "#f00", + x: 120, + }, + }, + "IMMEDIATELY", + ); + + commitTransaction(tx); + + // No tx synthetic entry expected in this scenario; undoing once applies the + // user entry's baseline. strokeColor should be patched to pre-tx baseline, + // while x should remain action-local baseline. + Keyboard.undo(); + const live = getElement(element.id)!; + expect(live.strokeColor).toBe("#000"); + expect(live.x).toBe(0); + expect(live.y).toBe(0); + }); + + it("overrides only polluted elements when one user entry updates multiple elements", () => { + const elementA = API.createElement({ + type: "rectangle", + id: "single-entry-multi-element-a", + strokeColor: "#000", + x: 0, + }); + const elementB = API.createElement({ + type: "rectangle", + id: "single-entry-multi-element-b", + strokeColor: "#222", + x: 0, + }); + setSceneBaseline([elementA, elementB]); + + const tx = h.app.createTransaction(); + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === elementA.id + ? newElementWith(el, { strokeColor: "#800080" }) + : el, + ), + }); + }); + + // One user history entry touches two elements: + // - elementA has tx pollution on strokeColor and should be patched. + // - elementB is unrelated and should keep action-local baseline. + applyElementUpdatesInSingleAction( + { + [elementA.id]: { strokeColor: "#f00" }, + [elementB.id]: { x: 240 }, + }, + "IMMEDIATELY", + ); + + commitTransaction(tx); + + Keyboard.undo(); + const liveA = getElement(elementA.id)!; + const liveB = getElement(elementB.id)!; + expect(liveA.strokeColor).toBe("#000"); + expect(liveA.x).toBe(0); + expect(liveB.x).toBe(0); + expect(liveB.strokeColor).toBe("#222"); + }); + + it("applies pre-tx baseline override after transaction is canceled", () => { + const element = API.createElement({ + type: "rectangle", + id: "cancel-no-patch", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080", x: 200 }) + : el, + ), + }); + }); + + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + + const summary = tx.cancel(); + expect(summary.status).toBe("canceled"); + expect(summary.historyCommitted).toBe(false); + + // Cancel ends the tx without a synthetic history entry. Undo for the + // interleaved user action should recover the pre-tx baseline. + Keyboard.undo(); + const live = getElement(element.id)!; + expect(live.strokeColor).toBe("#000"); + expect(live.x).toBe(200); + }); + + it("keeps semantics markers across undo/redo cycles before commit", () => { + const element = API.createElement({ + type: "rectangle", + id: "lifecycle-carry", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const tx = h.app.createTransaction(); + act(() => { + tx.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080", x: 200 }) + : el, + ), + }); + }); + + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + + // move user entry to redo and back to undo before commit + Keyboard.undo(); + Keyboard.redo(); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + commitTransaction(tx); + + Keyboard.undo(); + expect(getElement(element.id)!.x).toBe(0); + + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + }); + + it("supports concurrent transactions on different props without cross-override", () => { + const element = API.createElement({ + type: "rectangle", + id: "concurrent-different-props", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const txStroke = h.app.createTransaction(); + const txX = h.app.createTransaction(); + + act(() => { + txStroke.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080" }) + : el, + ), + }); + txX.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id ? newElementWith(el, { x: 200 }) : el, + ), + }); + }); + + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + applyElementUpdate(element.id, { x: 300 }, "IMMEDIATELY"); + + commitTransaction(txStroke); + commitTransaction(txX); + + Keyboard.undo(); + expect(getElement(element.id)!.x).toBe(0); + expect(getElement(element.id)!.strokeColor).toBe("#f00"); + + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#000"); + }); + + it("supports concurrent transactions on the same prop with deterministic started-seq priority", () => { + const element = API.createElement({ + type: "rectangle", + id: "concurrent-same-prop", + strokeColor: "#000", + x: 0, + }); + setSceneBaseline([element]); + + const txPurple = h.app.createTransaction(); + const txBlue = h.app.createTransaction(); + + act(() => { + txPurple.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#800080" }) + : el, + ), + }); + txBlue.updateScene({ + elements: h.app.scene + .getElementsIncludingDeleted() + .map((el) => + el.id === element.id + ? newElementWith(el, { strokeColor: "#0000ff" }) + : el, + ), + }); + }); + + applyElementUpdate(element.id, { strokeColor: "#f00" }, "IMMEDIATELY"); + + commitTransaction(txPurple); + commitTransaction(txBlue); + + // For txBlue, baseline was captured from live scene at its first update, + // which was already purple due to txPurple. + Keyboard.undo(); + expect(getElement(element.id)!.strokeColor).toBe("#800080"); + }); +}); diff --git a/packages/excalidraw/transaction.ts b/packages/excalidraw/transaction.ts index b86a1e8463..4b648d0cf9 100644 --- a/packages/excalidraw/transaction.ts +++ b/packages/excalidraw/transaction.ts @@ -1,12 +1,16 @@ import { randomId } from "@excalidraw/common"; import { CaptureUpdateAction, + Delta, deepCopyElement, + ElementsDelta, + mergeStoreDeltaSemantics, newElementWith, + type ElementUpdate, + type StoreDelta, + type TxUndoOverride, } from "@excalidraw/element"; -import type { ElementUpdate } from "@excalidraw/element"; - import type { Mutable } from "@excalidraw/common/utility-types"; import type { ExcalidrawElement, @@ -14,6 +18,12 @@ import type { SceneElementsMap, } from "@excalidraw/element/types"; +import { + HistoryDelta, + type HistoryBeforeRecordListener, + type HistoryEffectiveDeltaResolverContext, +} from "./history"; + import type { AppClassProperties, AppState, @@ -41,6 +51,27 @@ const LEDGER_IGNORED_PROPS = new Set([ ]); type ElementRecord = Record; +type ElementUpdatedEntry = Delta>; +type ElementUpdatedEntryMap = Record; +type TransactionMap = Map; +type TransactionLifecyclePhase = "active" | "committed" | "canceled"; +type TransactionLifecycleRecord = { + phase: TransactionLifecyclePhase; + startedSeq: number; + endedSeq?: number; +}; +type TransactionLifecycleMap = Map; +type TransactionHistoryBridge = { + onBeforeRecord: (callback: HistoryBeforeRecordListener) => () => void; + setEffectiveDeltaResolver: ( + resolver: + | (( + delta: HistoryDelta, + context: HistoryEffectiveDeltaResolverContext, + ) => HistoryDelta) + | null, + ) => void; +}; const isPlainObject = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); @@ -147,6 +178,306 @@ export const collectChangedElementIds = ( return [...changedIds]; }; +const serializeConsumedPropKey = (elementId: string, prop: string) => + `${elementId}\u0000${prop}`; + +const TX_UNDO_OVERRIDE_IGNORED_PROPS = new Set([ + "version", + "versionNonce", + "isDeleted", +]); + +const getUpdatedElementEntries = (delta: StoreDelta) => + delta.elements.updated as ElementUpdatedEntryMap; +const hasUpdatedElementEntries = (delta: StoreDelta) => + Object.keys(getUpdatedElementEntries(delta)).length > 0; + +const serializeIntermediateValue = (value: unknown): string => { + const serialize = (input: unknown, seen: WeakSet): string => { + if (input === null) { + return "null"; + } + + switch (typeof input) { + case "undefined": + return "undefined"; + case "boolean": + return input ? "boolean:true" : "boolean:false"; + case "number": + if (Number.isNaN(input)) { + return "number:NaN"; + } + if (Object.is(input, -0)) { + return "number:-0"; + } + return `number:${input}`; + case "bigint": + return `bigint:${input.toString()}`; + case "string": + return `string:${JSON.stringify(input)}`; + case "symbol": + return `symbol:${String(input)}`; + case "function": + return `function:${input.name}`; + case "object": + break; + default: + return `unknown:${String(input)}`; + } + + if (Array.isArray(input)) { + if (seen.has(input)) { + return "[CircularArray]"; + } + seen.add(input); + const serialized = `[${input + .map((item) => serialize(item, seen)) + .join(",")}]`; + seen.delete(input); + return serialized; + } + + if (isPlainObject(input)) { + if (seen.has(input)) { + return "{CircularObject}"; + } + seen.add(input); + const serialized = `{${Object.keys(input) + .sort() + .map((key) => `${JSON.stringify(key)}:${serialize(input[key], seen)}`) + .join(",")}}`; + seen.delete(input); + return serialized; + } + + try { + return `object:${JSON.stringify(input)}`; + } catch { + return `object:${Object.prototype.toString.call(input)}`; + } + }; + + return serialize(value, new WeakSet()); +}; + +type TxUndoOverrideCandidate = Omit; +type TxIntermediatePropValues = { + values: unknown[]; + signatures: Set; + latestValue: unknown; + hasLatestValue: boolean; +}; + +/** + * Tracks tx intermediate values and computes undo baseline override markers + * for durable user deltas recorded while tx is active. + */ +class TxUndoOverridePlanner { + private readonly intermediateValuesByElementProp = new Map< + string, + Map + >(); + private readonly consumedOverridePropKeys = new Set(); + + clear() { + this.consumedOverridePropKeys.clear(); + this.intermediateValuesByElementProp.clear(); + } + + recordStep( + before: ReadonlyMap, + after: ReadonlyMap, + ) { + for (const elementId of collectChangedElementIds(before, after)) { + const beforeElement = before.get(elementId) ?? null; + const afterElement = after.get(elementId) ?? null; + const touchedProps = collectTouchedProps(beforeElement, afterElement); + + if (!afterElement || touchedProps.has("*")) { + continue; + } + + for (const prop of touchedProps) { + this.recordIntermediateValue( + elementId, + prop, + getElementProp(afterElement, prop), + ); + } + } + } + + collectCandidatesForDurableDelta( + delta: StoreDelta, + getLedgerEntry: (elementId: string) => TransactionLedgerEntry | undefined, + reservedConsumedKeys: Set, + ): TxUndoOverrideCandidate[] { + const candidates: TxUndoOverrideCandidate[] = []; + + for (const [elementId, deltaEntry] of Object.entries( + getUpdatedElementEntries(delta), + )) { + const ledgerEntry = getLedgerEntry(elementId); + if (!ledgerEntry) { + continue; + } + + const elementCandidates = this.collectCandidatesForElement( + elementId, + deltaEntry, + ledgerEntry, + reservedConsumedKeys, + ); + if (elementCandidates.length > 0) { + candidates.push(...elementCandidates); + } + } + + return candidates; + } + + private collectCandidatesForElement( + elementId: string, + deltaEntry: ElementUpdatedEntry, + ledgerEntry: TransactionLedgerEntry, + reservedConsumedKeys: Set, + ): TxUndoOverrideCandidate[] { + const { baselineElement, touchedProps } = ledgerEntry; + if (!baselineElement || touchedProps.has("*")) { + return []; + } + + const candidates: TxUndoOverrideCandidate[] = []; + for (const [prop, deletedValue] of Object.entries(deltaEntry.deleted)) { + const candidate = this.createCandidateForProp({ + elementId, + prop, + deletedValue, + baselineElement, + touchedProps, + reservedConsumedKeys, + }); + if (candidate) { + candidates.push(candidate); + } + } + + return candidates; + } + + private createCandidateForProp(args: { + elementId: string; + prop: string; + deletedValue: unknown; + baselineElement: ExcalidrawElement; + touchedProps: Set; + reservedConsumedKeys: Set; + }): TxUndoOverrideCandidate | null { + const { elementId, prop, deletedValue, baselineElement, touchedProps } = + args; + + if (TX_UNDO_OVERRIDE_IGNORED_PROPS.has(prop) || !touchedProps.has(prop)) { + return null; + } + + const consumedPropKey = serializeConsumedPropKey(elementId, prop); + // Override only the first polluted user entry for this element+prop. + // Later user actions should keep action-local undo baselines. + if ( + this.consumedOverridePropKeys.has(consumedPropKey) || + args.reservedConsumedKeys.has(consumedPropKey) + ) { + return null; + } + + if (!this.matchesIntermediateValue(elementId, prop, deletedValue)) { + return null; + } + + return { + elementId, + prop, + expectedInsertedValue: deletedValue, + preTxBaselineValue: getElementProp(baselineElement, prop), + consumedKey: consumedPropKey, + }; + } + + markConsumed(consumedPropKey: string) { + this.consumedOverridePropKeys.add(consumedPropKey); + } + + private getOrCreatePropValues(elementId: string) { + const existing = this.intermediateValuesByElementProp.get(elementId); + if (existing) { + return existing; + } + + const created = new Map(); + this.intermediateValuesByElementProp.set(elementId, created); + return created; + } + + private recordIntermediateValue( + elementId: string, + prop: string, + value: unknown, + ) { + const propValues = this.getOrCreatePropValues(elementId); + const prevValues = propValues.get(prop); + if (prevValues) { + if ( + prevValues.hasLatestValue && + isLedgerValueEqual(prevValues.latestValue, value) + ) { + return; + } + + prevValues.values.push(value); + prevValues.signatures.add(serializeIntermediateValue(value)); + prevValues.latestValue = value; + prevValues.hasLatestValue = true; + return; + } + + propValues.set(prop, { + values: [value], + signatures: new Set([serializeIntermediateValue(value)]), + latestValue: value, + hasLatestValue: true, + }); + } + + private matchesIntermediateValue( + elementId: string, + prop: string, + candidate: unknown, + ) { + const propValues = this.intermediateValuesByElementProp + .get(elementId) + ?.get(prop); + if (!propValues) { + return false; + } + + const candidateSignature = serializeIntermediateValue(candidate); + if (!propValues.signatures.has(candidateSignature)) { + return false; + } + + if ( + propValues.hasLatestValue && + isLedgerValueEqual(propValues.latestValue, candidate) + ) { + return true; + } + + return propValues.values.some((value) => + isLedgerValueEqual(value, candidate), + ); + } +} + // --------------------------------------------------------------------------- // TransactionLedger // --------------------------------------------------------------------------- @@ -163,6 +494,11 @@ export class TransactionLedger { return this.entries.size > 0; } + /** Returns the ledger entry for an element, if any. */ + getEntry(elementId: string): TransactionLedgerEntry | undefined { + return this.entries.get(elementId); + } + /** Releases all ledger entries. */ clear() { this.entries.clear(); @@ -227,114 +563,233 @@ export class TransactionLedger { const elementsAfter = shallowCopySceneMap(live); for (const [elementId, entry] of this.entries) { - if (!entry.baselineElement) { - const liveElement = live.get(elementId) ?? null; - const targetElement = entry.targetElement; - if (!targetElement) { - continue; - } - if ( - !liveElement || - liveElement.isDeleted || - collectTouchedProps(targetElement, liveElement).size > 0 - ) { - continue; - } - elementsBefore.delete(elementId); - elementsAfter.set( - elementId, - deepCopyElement(targetElement) as OrderedExcalidrawElement, - ); - continue; - } - - if (!entry.targetElement) { - const liveElement = live.get(elementId) ?? null; - if (liveElement && !liveElement.isDeleted) { - continue; - } - elementsBefore.set( - elementId, - deepCopyElement(entry.baselineElement) as OrderedExcalidrawElement, - ); - elementsAfter.delete(elementId); - continue; - } - - const liveElement = live.get(elementId) ?? null; - const targetElement = entry.targetElement; - const baselineElement = entry.baselineElement; - const beforeElement = elementsBefore.get(elementId); - const afterElement = elementsAfter.get(elementId); - - if ( - !liveElement || - !baselineElement || - !targetElement || - !beforeElement || - !afterElement - ) { - continue; - } - - if (entry.touchedProps.has("*")) { - const hasLiveConflict = - collectTouchedProps(targetElement, liveElement).size > 0; - if (hasLiveConflict) { - continue; - } - elementsBefore.set( - elementId, - deepCopyElement(baselineElement) as OrderedExcalidrawElement, - ); - elementsAfter.set( - elementId, - deepCopyElement(targetElement) as OrderedExcalidrawElement, - ); - continue; - } - - // Deep-copy before mutating so we never touch live elements. - const clonedBefore = deepCopyElement( - beforeElement, - ) as Mutable; - const clonedAfter = deepCopyElement( - afterElement, - ) as Mutable; - elementsBefore.set(elementId, clonedBefore as OrderedExcalidrawElement); - elementsAfter.set(elementId, clonedAfter as OrderedExcalidrawElement); - - const mutableBefore = clonedBefore; - const mutableAfter = clonedAfter; - - let appliedProps = 0; - for (const prop of entry.touchedProps) { - const liveValue = getElementProp(liveElement, prop); - const targetValue = getElementProp(targetElement, prop); - const hasConflict = !isLedgerValueEqual(liveValue, targetValue); - if (hasConflict) { - continue; - } - - setOrderedElementProp( - mutableBefore, - prop, - getElementProp(baselineElement, prop), - ); - setOrderedElementProp(mutableAfter, prop, targetValue); - appliedProps += 1; - } - - if (appliedProps > 0) { - mutableBefore.version = baselineElement.version; - mutableBefore.versionNonce = baselineElement.versionNonce; - mutableAfter.version = targetElement.version; - mutableAfter.versionNonce = targetElement.versionNonce; - } + this.reconcileEntrySnapshots( + elementId, + entry, + live, + elementsBefore, + elementsAfter, + ); } return { elementsBefore, elementsAfter }; } + + private reconcileEntrySnapshots( + elementId: string, + entry: TransactionLedgerEntry, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + if (!entry.baselineElement) { + this.applyCreatedElementSnapshots( + elementId, + entry.targetElement, + live, + elementsBefore, + elementsAfter, + ); + return; + } + + if (!entry.targetElement) { + this.applyDeletedElementSnapshots( + elementId, + entry.baselineElement, + live, + elementsBefore, + elementsAfter, + ); + return; + } + + this.applyUpdatedElementSnapshots( + elementId, + entry, + live, + elementsBefore, + elementsAfter, + ); + } + + private applyCreatedElementSnapshots( + elementId: string, + targetElement: ExcalidrawElement | null, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + if (!targetElement) { + return; + } + + const liveElement = live.get(elementId) ?? null; + if ( + !liveElement || + liveElement.isDeleted || + collectTouchedProps(targetElement, liveElement).size > 0 + ) { + return; + } + + elementsBefore.delete(elementId); + elementsAfter.set( + elementId, + deepCopyElement(targetElement) as OrderedExcalidrawElement, + ); + } + + private applyDeletedElementSnapshots( + elementId: string, + baselineElement: ExcalidrawElement, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + const liveElement = live.get(elementId) ?? null; + if (liveElement && !liveElement.isDeleted) { + return; + } + + elementsBefore.set( + elementId, + deepCopyElement(baselineElement) as OrderedExcalidrawElement, + ); + elementsAfter.delete(elementId); + } + + private applyUpdatedElementSnapshots( + elementId: string, + entry: TransactionLedgerEntry, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + const liveElement = live.get(elementId) ?? null; + const targetElement = entry.targetElement; + const baselineElement = entry.baselineElement; + const beforeElement = elementsBefore.get(elementId); + const afterElement = elementsAfter.get(elementId); + + if ( + !liveElement || + !baselineElement || + !targetElement || + !beforeElement || + !afterElement + ) { + return; + } + + if (entry.touchedProps.has("*")) { + this.applyWholeElementSnapshots( + elementId, + baselineElement, + targetElement, + liveElement, + elementsBefore, + elementsAfter, + ); + return; + } + + this.applyPerPropSnapshots({ + entry, + liveElement, + baselineElement, + targetElement, + beforeElement, + afterElement, + elementId, + elementsBefore, + elementsAfter, + }); + } + + private applyWholeElementSnapshots( + elementId: string, + baselineElement: ExcalidrawElement, + targetElement: ExcalidrawElement, + liveElement: ExcalidrawElement, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + const hasLiveConflict = + collectTouchedProps(targetElement, liveElement).size > 0; + if (hasLiveConflict) { + return; + } + + elementsBefore.set( + elementId, + deepCopyElement(baselineElement) as OrderedExcalidrawElement, + ); + elementsAfter.set( + elementId, + deepCopyElement(targetElement) as OrderedExcalidrawElement, + ); + } + + private applyPerPropSnapshots(args: { + entry: TransactionLedgerEntry; + liveElement: ExcalidrawElement; + baselineElement: ExcalidrawElement; + targetElement: ExcalidrawElement; + beforeElement: ExcalidrawElement; + afterElement: ExcalidrawElement; + elementId: string; + elementsBefore: SceneElementsMap; + elementsAfter: SceneElementsMap; + }) { + const { + entry, + liveElement, + baselineElement, + targetElement, + beforeElement, + afterElement, + elementId, + elementsBefore, + elementsAfter, + } = args; + + // Deep-copy before mutating so we never touch live elements. + const mutableBefore = deepCopyElement( + beforeElement, + ) as Mutable; + const mutableAfter = deepCopyElement( + afterElement, + ) as Mutable; + elementsBefore.set(elementId, mutableBefore as OrderedExcalidrawElement); + elementsAfter.set(elementId, mutableAfter as OrderedExcalidrawElement); + + let appliedProps = 0; + for (const prop of entry.touchedProps) { + const liveValue = getElementProp(liveElement, prop); + const targetValue = getElementProp(targetElement, prop); + if (!isLedgerValueEqual(liveValue, targetValue)) { + continue; + } + + setOrderedElementProp( + mutableBefore, + prop, + getElementProp(baselineElement, prop), + ); + setOrderedElementProp(mutableAfter, prop, targetValue); + appliedProps += 1; + } + + if (appliedProps === 0) { + return; + } + + mutableBefore.version = baselineElement.version; + mutableBefore.versionNonce = baselineElement.versionNonce; + mutableAfter.version = targetElement.version; + mutableAfter.versionNonce = targetElement.versionNonce; + } } // --------------------------------------------------------------------------- @@ -413,16 +868,20 @@ export class Transaction { public readonly id = `tx-${randomId()}`; private readonly app: AppClassProperties; + private readonly manager: TransactionManager; private readonly ledger = new TransactionLedger(); + private readonly undoOverridePlanner = new TxUndoOverridePlanner(); private readonly initialAppState: Partial; private accumulatedAppState: Record = {}; private statusValue: TransactionStatus = "active"; private cachedSummary: TransactionSummary | null = null; - constructor(app: AppClassProperties) { + constructor(app: AppClassProperties, manager: TransactionManager) { this.app = app; + this.manager = manager; this.initialAppState = { ...app.store.snapshot.appState }; + this.manager.registerTransaction(this); } get status(): TransactionStatus { @@ -437,6 +896,42 @@ export class Transaction { } } + private closeTransaction() { + this.manager.unregisterTransaction(this.id); + this.undoOverridePlanner.clear(); + } + + public collectUndoOverridesForDelta( + delta: StoreDelta, + reservedConsumedKeys: Set, + ): TxUndoOverride[] { + if (this.statusValue !== "active") { + return []; + } + + const candidates = + this.undoOverridePlanner.collectCandidatesForDurableDelta( + delta, + (elementId) => this.ledger.getEntry(elementId), + reservedConsumedKeys, + ); + + if (candidates.length === 0) { + return []; + } + + const overrides: TxUndoOverride[] = []; + for (const candidate of candidates) { + this.undoOverridePlanner.markConsumed(candidate.consumedKey); + overrides.push({ + txId: this.id, + ...candidate, + }); + } + + return overrides; + } + updateScene(data: { elements?: SceneData["elements"]; appState?: Pick | null; @@ -458,6 +953,8 @@ export class Transaction { // Snapshot after const after = this.app.scene.getElementsMapIncludingDeleted(); + this.undoOverridePlanner.recordStep(before, after); + // Record element diff into ledger this.ledger.recordStep(before, after); @@ -531,51 +1028,12 @@ export class Transaction { return this.cachedSummary; } - if (this.statusValue === "active") { - this.statusValue = "committed"; - } - - let historyCommitted = false; - - const hasWork = - this.ledger.hasEntries() || - Object.keys(this.accumulatedAppState).length > 0; - - if (this.statusValue === "committed" && hasWork) { - const liveMap = this.app.scene.getElementsMapIncludingDeleted(); - const { elementsBefore, elementsAfter } = - this.ledger.buildSyntheticSnapshots(liveMap); - - // Resolve appState for the history entry. - const hasAccumulatedAppState = - Object.keys(this.accumulatedAppState).length > 0; - - let appStateDelta: Partial | undefined; - if (hasAccumulatedAppState) { - if (options?.resolveAppState) { - const context: AppStateResolverContext = { - initial: this.initialAppState, - accumulated: this.accumulatedAppState as Partial, - live: { ...this.app.store.snapshot.appState }, - }; - appStateDelta = options.resolveAppState(context); - } else { - appStateDelta = this.accumulatedAppState as Partial; - } - } - - const hasAppStateChanges = - !!appStateDelta && Object.keys(appStateDelta).length > 0; - - historyCommitted = this.app.store.commitSyntheticIncrement({ - logicalBefore: { elements: elementsBefore }, - logicalAfter: { - elements: elementsAfter, - appState: hasAppStateChanges ? appStateDelta : undefined, - }, - }); - } + this.markCommittedIfActive(); + const historyCommitted = this.shouldCommitHistory() + ? this.commitHistoryEntry(options) + : false; + this.closeTransaction(); this.cachedSummary = { id: this.id, status: this.statusValue, @@ -585,6 +1043,65 @@ export class Transaction { return this.cachedSummary; } + private markCommittedIfActive() { + if (this.statusValue === "active") { + this.statusValue = "committed"; + this.manager.markTransactionPhase(this.id, "committed"); + } + } + + private shouldCommitHistory() { + return this.statusValue === "committed" && this.hasPendingWork(); + } + + private hasPendingWork() { + return this.ledger.hasEntries() || this.hasAccumulatedAppStateIntent(); + } + + private hasAccumulatedAppStateIntent() { + return Object.keys(this.accumulatedAppState).length > 0; + } + + private commitHistoryEntry(options?: { resolveAppState?: AppStateResolver }) { + const liveMap = this.app.scene.getElementsMapIncludingDeleted(); + const { elementsBefore, elementsAfter } = + this.ledger.buildSyntheticSnapshots(liveMap); + + const appStateDelta = this.resolveCommitAppStateDelta(options); + + return this.app.store.commitSyntheticIncrement({ + logicalBefore: { elements: elementsBefore }, + logicalAfter: { + elements: elementsAfter, + appState: appStateDelta, + }, + }); + } + + private resolveCommitAppStateDelta(options?: { + resolveAppState?: AppStateResolver; + }): Partial | undefined { + if (!this.hasAccumulatedAppStateIntent()) { + return undefined; + } + + if (!options?.resolveAppState) { + return this.accumulatedAppState as Partial; + } + + const context: AppStateResolverContext = { + initial: this.initialAppState, + accumulated: this.accumulatedAppState as Partial, + live: { ...this.app.store.snapshot.appState }, + }; + const resolved = options.resolveAppState(context); + + if (!resolved || Object.keys(resolved).length === 0) { + return undefined; + } + return resolved; + } + cancel(): TransactionSummary { if (this.cachedSummary) { return this.cachedSummary; @@ -592,8 +1109,10 @@ export class Transaction { if (this.statusValue === "active") { this.statusValue = "canceled"; + this.manager.markTransactionPhase(this.id, "canceled"); } + this.closeTransaction(); this.cachedSummary = { id: this.id, status: this.statusValue, @@ -613,12 +1132,192 @@ export class Transaction { */ export class TransactionManager { private readonly app: AppClassProperties; + private readonly activeTransactions: TransactionMap = new Map(); + private readonly activeTransactionsByStartedSeqDesc: Transaction[] = []; + private readonly transactionLifecycle: TransactionLifecycleMap = new Map(); + private detachBeforeRecordHook: (() => void) | null = null; + private sequence = 0; constructor(app: AppClassProperties) { this.app = app; } + /** + * Binds transaction bookkeeping to history lifecycle hooks. + * Call once during app initialization. + */ + attachHistory(history: TransactionHistoryBridge) { + this.detachBeforeRecordHook?.(); + history.setEffectiveDeltaResolver((delta, context) => + this.resolveEffectiveDelta(delta, context), + ); + this.detachBeforeRecordHook = history.onBeforeRecord((delta) => + this.onDurableIncrement(delta), + ); + } + + registerTransaction(tx: Transaction) { + const startedSeq = this.nextSequence(); + this.activeTransactions.set(tx.id, tx); + this.activeTransactionsByStartedSeqDesc.unshift(tx); + this.transactionLifecycle.set(tx.id, { + phase: "active", + startedSeq, + }); + } + + unregisterTransaction(txId: string) { + this.activeTransactions.delete(txId); + const txIndex = this.activeTransactionsByStartedSeqDesc.findIndex( + (tx) => tx.id === txId, + ); + if (txIndex >= 0) { + this.activeTransactionsByStartedSeqDesc.splice(txIndex, 1); + } + } + + markTransactionPhase( + txId: string, + phase: Exclude, + ) { + const record = this.transactionLifecycle.get(txId); + if (!record || record.phase !== "active") { + return; + } + + record.phase = phase; + record.endedSeq = this.nextSequence(); + } + + onDurableIncrement(delta: StoreDelta) { + if (this.activeTransactions.size === 0) { + return; + } + if (!hasUpdatedElementEntries(delta)) { + return; + } + + const txUndoOverrides = this.collectUndoOverrides(delta); + if (txUndoOverrides.length === 0) { + return; + } + + mergeStoreDeltaSemantics(delta, { txUndoOverrides }); + } + + private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] { + const overrides: TxUndoOverride[] = []; + const reservedConsumedKeys = new Set(); + + for (const tx of this.activeTransactionsByStartedSeqDesc) { + const txOverrides = tx.collectUndoOverridesForDelta( + delta, + reservedConsumedKeys, + ); + + for (const override of txOverrides) { + if (reservedConsumedKeys.has(override.consumedKey)) { + continue; + } + + reservedConsumedKeys.add(override.consumedKey); + overrides.push(override); + } + } + + return overrides; + } + + private resolveEffectiveDelta( + delta: HistoryDelta, + _context: HistoryEffectiveDeltaResolverContext, + ): HistoryDelta { + const txUndoOverrides = delta.semantics?.txUndoOverrides; + if (!txUndoOverrides || txUndoOverrides.length === 0) { + return delta; + } + + const updatedEntries = getUpdatedElementEntries(delta); + const insertedOverridesByElement = new Map< + string, + Record + >(); + + for (const override of txUndoOverrides) { + if (!this.shouldApplyUndoOverride(override.txId)) { + continue; + } + + const currentEntry = updatedEntries[override.elementId]; + if (!currentEntry) { + continue; + } + + const currentInsertedValue = currentEntry.inserted[override.prop]; + if ( + !isLedgerValueEqual( + currentInsertedValue, + override.expectedInsertedValue, + ) + ) { + // Guard against over-applying once the delta has already evolved. + continue; + } + + const elementOverrides = insertedOverridesByElement.get( + override.elementId, + ); + if (elementOverrides) { + elementOverrides[override.prop] = override.preTxBaselineValue; + } else { + insertedOverridesByElement.set(override.elementId, { + [override.prop]: override.preTxBaselineValue, + }); + } + } + + if (insertedOverridesByElement.size === 0) { + return delta; + } + + const nextUpdatedEntries: ElementUpdatedEntryMap = { + ...updatedEntries, + }; + for (const [elementId, insertedOverrides] of insertedOverridesByElement) { + const currentEntry = updatedEntries[elementId]; + if (!currentEntry) { + continue; + } + + nextUpdatedEntries[elementId] = Delta.create( + { ...currentEntry.deleted }, + { ...currentEntry.inserted, ...insertedOverrides }, + ); + } + + const effectiveElements = ElementsDelta.create( + delta.elements.added, + delta.elements.removed, + nextUpdatedEntries, + ); + + return HistoryDelta.create(effectiveElements, delta.appState, { + id: delta.id, + semantics: delta.semantics, + }) as HistoryDelta; + } + + private shouldApplyUndoOverride(txId: string): boolean { + const lifecycle = this.transactionLifecycle.get(txId); + return !!lifecycle && lifecycle.phase !== "active"; + } + + private nextSequence() { + this.sequence += 1; + return this.sequence; + } + create(): Transaction { - return new Transaction(this.app); + return new Transaction(this.app, this); } }