tx markers/effective-delta + perf tune
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+851
-152
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user