tx markers/effective-delta + perf tune

This commit is contained in:
Ryan Di
2026-04-23 14:34:30 +10:00
parent ef9afb0d37
commit 75192db663
5 changed files with 1560 additions and 174 deletions
+112 -9
View File
@@ -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<Record<string, unknown>>
>;
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<StoreDelta>) {
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<StoreDelta>) {
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() {
+1 -1
View File
@@ -835,6 +835,7 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
};
this.fonts = new Fonts(this.scene);
this.history = new History(this.store);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
+60 -10
View File
@@ -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);
}
}
+536 -2
View File
@@ -41,6 +41,23 @@ const applyElementUpdate = (
});
};
const applyElementUpdatesInSingleAction = (
updatesById: Record<string, ElementUpdate<OrderedExcalidrawElement>>,
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(<Excalidraw handleKeyboardGlobally={true} />);
});
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");
});
});
File diff suppressed because it is too large Load Diff