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.
|
* Represents a captured delta by the Store.
|
||||||
*/
|
*/
|
||||||
@@ -570,6 +653,7 @@ export class StoreDelta {
|
|||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
public readonly elements: ElementsDelta,
|
public readonly elements: ElementsDelta,
|
||||||
public readonly appState: AppStateDelta,
|
public readonly appState: AppStateDelta,
|
||||||
|
public semantics?: StoreDeltaSemantics,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -579,12 +663,16 @@ export class StoreDelta {
|
|||||||
elements: ElementsDelta,
|
elements: ElementsDelta,
|
||||||
appState: AppStateDelta,
|
appState: AppStateDelta,
|
||||||
opts: {
|
opts: {
|
||||||
id: string;
|
id?: string;
|
||||||
} = {
|
semantics?: StoreDeltaSemantics;
|
||||||
id: randomId(),
|
} = {},
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
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.
|
* Restore a store delta instance from a DTO.
|
||||||
*/
|
*/
|
||||||
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||||
const { id, elements, appState } = storeDeltaDTO;
|
const { id, elements, appState, semantics } = storeDeltaDTO;
|
||||||
return new this(
|
return new this(
|
||||||
id,
|
id,
|
||||||
ElementsDelta.restore(elements),
|
ElementsDelta.restore(elements),
|
||||||
AppStateDelta.restore(appState),
|
AppStateDelta.restore(appState),
|
||||||
|
cloneStoreDeltaSemantics(semantics),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,11 +713,17 @@ export class StoreDelta {
|
|||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
appState: { delta: appStateDelta },
|
appState: { delta: appStateDelta },
|
||||||
|
semantics,
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated);
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
const appState = AppStateDelta.create(appStateDelta);
|
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`.
|
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public static inverse(delta: 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,
|
nextElements: SceneElementsMap,
|
||||||
modifierOptions?: "deleted" | "inserted",
|
modifierOptions?: "deleted" | "inserted",
|
||||||
): StoreDelta {
|
): StoreDelta {
|
||||||
return this.create(
|
const nextDelta = this.create(
|
||||||
delta.elements.applyLatestChanges(
|
delta.elements.applyLatestChanges(
|
||||||
prevElements,
|
prevElements,
|
||||||
nextElements,
|
nextElements,
|
||||||
@@ -694,8 +794,11 @@ export class StoreDelta {
|
|||||||
delta.appState,
|
delta.appState,
|
||||||
{
|
{
|
||||||
id: delta.id,
|
id: delta.id,
|
||||||
|
semantics: delta.semantics,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
pruneStoreDeltaSemantics(nextDelta);
|
||||||
|
return nextDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
public static empty() {
|
||||||
|
|||||||
@@ -835,6 +835,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.store = new Store(this);
|
this.store = new Store(this);
|
||||||
this.history = new History(this.store);
|
this.history = new History(this.store);
|
||||||
this.transactionManager = new TransactionManager(this);
|
this.transactionManager = new TransactionManager(this);
|
||||||
|
this.transactionManager.attachHistory(this.history);
|
||||||
|
|
||||||
this.excalidrawContainerValue = {
|
this.excalidrawContainerValue = {
|
||||||
container: this.excalidrawContainerRef.current,
|
container: this.excalidrawContainerRef.current,
|
||||||
@@ -842,7 +843,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.fonts = new Fonts(this.scene);
|
this.fonts = new Fonts(this.scene);
|
||||||
this.history = new History(this.store);
|
|
||||||
|
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
this.actionManager.registerAction(createUndoAction(this.history));
|
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 {
|
export class History {
|
||||||
public readonly onHistoryChangedEmitter = new Emitter<
|
public readonly onHistoryChangedEmitter = new Emitter<
|
||||||
[HistoryChangedEvent]
|
[HistoryChangedEvent]
|
||||||
>();
|
>();
|
||||||
|
private readonly onBeforeRecordEmitter = new Emitter<[StoreDelta]>();
|
||||||
|
private effectiveDeltaResolver: HistoryEffectiveDeltaResolver | null = null;
|
||||||
|
|
||||||
public readonly undoStack: HistoryDelta[] = [];
|
public readonly undoStack: HistoryDelta[] = [];
|
||||||
public readonly redoStack: HistoryDelta[] = [];
|
public readonly redoStack: HistoryDelta[] = [];
|
||||||
@@ -105,6 +117,20 @@ export class History {
|
|||||||
|
|
||||||
constructor(private readonly store: Store) {}
|
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() {
|
public clear() {
|
||||||
this.undoStack.length = 0;
|
this.undoStack.length = 0;
|
||||||
this.redoStack.length = 0;
|
this.redoStack.length = 0;
|
||||||
@@ -119,6 +145,8 @@ export class History {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onBeforeRecordEmitter.trigger(delta);
|
||||||
|
|
||||||
// construct history entry, so once it's emitted, it's not recorded again
|
// construct history entry, so once it's emitted, it's not recorded again
|
||||||
const historyDelta = HistoryDelta.inverse(delta);
|
const historyDelta = HistoryDelta.inverse(delta);
|
||||||
|
|
||||||
@@ -131,15 +159,14 @@ export class History {
|
|||||||
this.redoStack.length = 0;
|
this.redoStack.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onHistoryChangedEmitter.trigger(
|
this.emitHistoryChanged();
|
||||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public undo(elements: SceneElementsMap, appState: AppState) {
|
public undo(elements: SceneElementsMap, appState: AppState) {
|
||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
"undo",
|
||||||
() => History.pop(this.undoStack),
|
() => History.pop(this.undoStack),
|
||||||
(entry: HistoryDelta) => History.push(this.redoStack, entry),
|
(entry: HistoryDelta) => History.push(this.redoStack, entry),
|
||||||
);
|
);
|
||||||
@@ -149,6 +176,7 @@ export class History {
|
|||||||
return this.perform(
|
return this.perform(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
|
"redo",
|
||||||
() => History.pop(this.redoStack),
|
() => History.pop(this.redoStack),
|
||||||
(entry: HistoryDelta) => History.push(this.undoStack, entry),
|
(entry: HistoryDelta) => History.push(this.undoStack, entry),
|
||||||
);
|
);
|
||||||
@@ -157,6 +185,7 @@ export class History {
|
|||||||
private perform(
|
private perform(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
direction: HistoryPerformDirection,
|
||||||
pop: () => HistoryDelta | null,
|
pop: () => HistoryDelta | null,
|
||||||
push: (entry: HistoryDelta) => void,
|
push: (entry: HistoryDelta) => void,
|
||||||
): [SceneElementsMap, AppState] | void {
|
): [SceneElementsMap, AppState] | void {
|
||||||
@@ -177,9 +206,15 @@ export class History {
|
|||||||
|
|
||||||
// iterate through the history entries in case they result in no visible changes
|
// iterate through the history entries in case they result in no visible changes
|
||||||
while (historyDelta) {
|
while (historyDelta) {
|
||||||
|
// Roundtrip invariant: whichever delta we execute must be the one that
|
||||||
|
// continues through applyLatestChanges -> inverse -> opposite stack.
|
||||||
|
let entryToPush = historyDelta;
|
||||||
try {
|
try {
|
||||||
|
const effectiveDelta = this.resolveEffectiveDelta(historyDelta, {
|
||||||
|
direction,
|
||||||
|
});
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
||||||
|
|
||||||
const prevElements = prevSnapshot.elements;
|
const prevElements = prevSnapshot.elements;
|
||||||
const nextSnapshot = prevSnapshot.maybeClone(
|
const nextSnapshot = prevSnapshot.maybeClone(
|
||||||
@@ -190,7 +225,7 @@ export class History {
|
|||||||
|
|
||||||
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
const delta = HistoryDelta.applyLatestChanges(
|
const delta = HistoryDelta.applyLatestChanges(
|
||||||
historyDelta,
|
effectiveDelta,
|
||||||
prevElements,
|
prevElements,
|
||||||
nextElements,
|
nextElements,
|
||||||
);
|
);
|
||||||
@@ -203,12 +238,12 @@ export class History {
|
|||||||
delta,
|
delta,
|
||||||
});
|
});
|
||||||
|
|
||||||
historyDelta = delta;
|
entryToPush = delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
prevSnapshot = nextSnapshot;
|
prevSnapshot = nextSnapshot;
|
||||||
} finally {
|
} finally {
|
||||||
push(historyDelta);
|
push(entryToPush);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (containsVisibleChange) {
|
if (containsVisibleChange) {
|
||||||
@@ -222,12 +257,16 @@ export class History {
|
|||||||
} finally {
|
} finally {
|
||||||
// trigger the history change event before returning completely
|
// trigger the history change event before returning completely
|
||||||
// also trigger it just once, no need doing so on each entry
|
// also trigger it just once, no need doing so on each entry
|
||||||
this.onHistoryChangedEmitter.trigger(
|
this.emitHistoryChanged();
|
||||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitHistoryChanged() {
|
||||||
|
this.onHistoryChangedEmitter.trigger(
|
||||||
|
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
|
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
|
||||||
if (!stack.length) {
|
if (!stack.length) {
|
||||||
return null;
|
return null;
|
||||||
@@ -246,4 +285,15 @@ export class History {
|
|||||||
const inversedEntry = HistoryDelta.inverse(entry);
|
const inversedEntry = HistoryDelta.inverse(entry);
|
||||||
return stack.push(inversedEntry);
|
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[]) => {
|
const setSceneBaseline = (elements: readonly ExcalidrawElement[]) => {
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements,
|
elements,
|
||||||
@@ -788,15 +805,532 @@ describe("createTransaction live-wins-per-prop behavior", () => {
|
|||||||
expect(live.backgroundColor).toBe("#fff");
|
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();
|
const element = setupSamePropertyConflictScenario();
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
|
|
||||||
const live = getElement(element.id)!;
|
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.x).toBe(0);
|
||||||
expect(live.backgroundColor).toBe("#fff");
|
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