refactor store to isolate synthetic tx commits and use markers instead

This commit is contained in:
Ryan Di
2026-04-24 16:15:32 +10:00
parent 801c936fd4
commit 4be8dd9879
3 changed files with 73 additions and 63 deletions
+52 -41
View File
@@ -116,8 +116,8 @@ export class Store {
* *
* Builds StoreSnapshot → StoreChange → StoreDelta from the provided * Builds StoreSnapshot → StoreChange → StoreDelta from the provided
* logical before/after element maps and optional appState patches, then * logical before/after element maps and optional appState patches, then
* flushes the resulting durable increment immediately (bypassing the * emits the resulting durable increment immediately through an isolated
* normal componentDidUpdate → store.commit() cycle). * path that does not flush pending micro actions.
* *
* appState patches are merged on top of the current observed appState * appState patches are merged on top of the current observed appState
* baseline so only the provided keys participate in the synthetic diff. * baseline so only the provided keys participate in the synthetic diff.
@@ -167,16 +167,7 @@ export class Store {
return false; return false;
} }
this.scheduleMicroAction({ this.emitIsolatedDurableIncrement(change, delta);
action: CaptureUpdateAction.IMMEDIATELY,
change,
delta,
});
// Flush immediately so the durable increment is emitted without waiting
// for the next componentDidUpdate → store.commit() cycle. This is safe
// because commitSyntheticIncrement is never called from within commit().
this.flushMicroActions();
return true; return true;
} }
@@ -316,6 +307,24 @@ export class Store {
} }
} }
/**
* Emits a synthetic durable increment immediately without draining the
* regular micro-action queue.
*/
private emitIsolatedDurableIncrement(change: StoreChange, delta: StoreDelta) {
const nextSnapshot = this.applyChangeToSnapshot(change);
if (!nextSnapshot) {
return;
}
try {
this.emitDurableIncrement(nextSnapshot, change, delta);
} finally {
this.snapshot = nextSnapshot;
}
}
/** /**
* Performs change calculation and emits an ephemeral increment. * Performs change calculation and emits an ephemeral increment.
* *
@@ -563,7 +572,7 @@ export class EphemeralIncrement extends StoreIncrement {
} }
/** /**
* Serializable semantics marker used to recover pre-tx undo baselines * Serializable delta marker used to recover pre-tx undo baselines
* without rewriting history stack entries. * without rewriting history stack entries.
*/ */
export type TxUndoOverride = { export type TxUndoOverride = {
@@ -575,7 +584,7 @@ export type TxUndoOverride = {
consumedKey: string; consumedKey: string;
}; };
export type StoreDeltaSemantics = { export type StoreDeltaMarkers = {
txUndoOverrides?: TxUndoOverride[]; txUndoOverrides?: TxUndoOverride[];
}; };
@@ -583,18 +592,18 @@ const cloneTxUndoOverride = (override: TxUndoOverride): TxUndoOverride => ({
...override, ...override,
}); });
const cloneStoreDeltaSemantics = ( const cloneStoreDeltaMarkers = (
semantics: StoreDeltaSemantics | undefined, markers: StoreDeltaMarkers | undefined,
): StoreDeltaSemantics | undefined => ): StoreDeltaMarkers | undefined =>
semantics markers
? { ? {
...semantics, ...markers,
txUndoOverrides: semantics.txUndoOverrides?.map(cloneTxUndoOverride), txUndoOverrides: markers.txUndoOverrides?.map(cloneTxUndoOverride),
} }
: undefined; : undefined;
const pruneStoreDeltaSemantics = (delta: StoreDelta) => { const pruneStoreDeltaMarkers = (delta: StoreDelta) => {
const txUndoOverrides = delta.semantics?.txUndoOverrides; const txUndoOverrides = delta.markers?.txUndoOverrides;
if (!txUndoOverrides || txUndoOverrides.length === 0) { if (!txUndoOverrides || txUndoOverrides.length === 0) {
return; return;
} }
@@ -616,28 +625,28 @@ const pruneStoreDeltaSemantics = (delta: StoreDelta) => {
} }
if (nextTxUndoOverrides.length === 0) { if (nextTxUndoOverrides.length === 0) {
delta.semantics = undefined; delta.markers = undefined;
return; return;
} }
delta.semantics = { delta.markers = {
...delta.semantics, ...delta.markers,
txUndoOverrides: nextTxUndoOverrides.map(cloneTxUndoOverride), txUndoOverrides: nextTxUndoOverrides.map(cloneTxUndoOverride),
}; };
}; };
export const mergeStoreDeltaSemantics = ( export const mergeStoreDeltaMarkers = (
delta: StoreDelta, delta: StoreDelta,
semantics: StoreDeltaSemantics, markers: StoreDeltaMarkers,
) => { ) => {
const txUndoOverrides = semantics.txUndoOverrides ?? []; const txUndoOverrides = markers.txUndoOverrides ?? [];
if (txUndoOverrides.length === 0) { if (txUndoOverrides.length === 0) {
return; return;
} }
const existing = delta.semantics?.txUndoOverrides ?? []; const existing = delta.markers?.txUndoOverrides ?? [];
delta.semantics = { delta.markers = {
...delta.semantics, ...delta.markers,
txUndoOverrides: [ txUndoOverrides: [
...existing.map(cloneTxUndoOverride), ...existing.map(cloneTxUndoOverride),
...txUndoOverrides.map(cloneTxUndoOverride), ...txUndoOverrides.map(cloneTxUndoOverride),
@@ -653,7 +662,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, public markers?: StoreDeltaMarkers,
) {} ) {}
/** /**
@@ -664,14 +673,14 @@ export class StoreDelta {
appState: AppStateDelta, appState: AppStateDelta,
opts: { opts: {
id?: string; id?: string;
semantics?: StoreDeltaSemantics; markers?: StoreDeltaMarkers;
} = {}, } = {},
) { ) {
return new this( return new this(
opts.id ?? randomId(), opts.id ?? randomId(),
elements, elements,
appState, appState,
cloneStoreDeltaSemantics(opts.semantics), cloneStoreDeltaMarkers(opts.markers),
); );
} }
@@ -697,12 +706,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, semantics } = storeDeltaDTO; const { id, elements, appState, markers } = storeDeltaDTO;
return new this( return new this(
id, id,
ElementsDelta.restore(elements), ElementsDelta.restore(elements),
AppStateDelta.restore(appState), AppStateDelta.restore(appState),
cloneStoreDeltaSemantics(semantics), cloneStoreDeltaMarkers(markers),
); );
} }
@@ -713,7 +722,7 @@ export class StoreDelta {
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta }, appState: { delta: appStateDelta },
semantics, markers,
}: 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);
@@ -722,7 +731,7 @@ export class StoreDelta {
id, id,
elements, elements,
appState, appState,
cloneStoreDeltaSemantics(semantics), cloneStoreDeltaMarkers(markers),
); );
} }
@@ -735,8 +744,10 @@ export class StoreDelta {
for (const delta of deltas) { for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements); aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState); aggregatedDelta.appState.squash(delta.appState);
mergeStoreDeltaMarkers(aggregatedDelta, delta.markers ?? {});
} }
pruneStoreDeltaMarkers(aggregatedDelta);
return aggregatedDelta; return aggregatedDelta;
} }
@@ -747,7 +758,7 @@ export class StoreDelta {
const inversed = this.create( const inversed = this.create(
delta.elements.inverse(), delta.elements.inverse(),
delta.appState.inverse(), delta.appState.inverse(),
{ semantics: delta.semantics }, { markers: delta.markers },
); );
return inversed; return inversed;
} }
@@ -794,10 +805,10 @@ export class StoreDelta {
delta.appState, delta.appState,
{ {
id: delta.id, id: delta.id,
semantics: delta.semantics, markers: delta.markers,
}, },
); );
pruneStoreDeltaSemantics(nextDelta); pruneStoreDeltaMarkers(nextDelta);
return nextDelta; return nextDelta;
} }
+16 -17
View File
@@ -449,28 +449,27 @@ const stripProps = (
}, {} as Record<string, any>); }, {} as Record<string, any>);
export const checkpointHistory = (history: History, name: string) => { export const checkpointHistory = (history: History, name: string) => {
expect( const normalizeHistoryEntry = (entry: History["undoStack"][number]) => {
history.undoStack.map((x) => ({ const { markers, ...rest } = entry;
...x,
return {
...rest,
...(markers ? { markers } : {}),
elements: { elements: {
...x.elements, ...entry.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]), added: stripProps(entry.elements.added, ["seed", "versionNonce"]),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]), removed: stripProps(entry.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]), updated: stripProps(entry.elements.updated, ["seed", "versionNonce"]),
}, },
})), };
};
expect(
history.undoStack.map(normalizeHistoryEntry),
).toMatchSnapshot(`[${name}] undo stack`); ).toMatchSnapshot(`[${name}] undo stack`);
expect( expect(
history.redoStack.map((x) => ({ history.redoStack.map(normalizeHistoryEntry),
...x,
elements: {
...x.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
},
})),
).toMatchSnapshot(`[${name}] redo stack`); ).toMatchSnapshot(`[${name}] redo stack`);
}; };
+5 -5
View File
@@ -1,7 +1,7 @@
import { import {
Delta, Delta,
ElementsDelta, ElementsDelta,
mergeStoreDeltaSemantics, mergeStoreDeltaMarkers,
type StoreDelta, type StoreDelta,
type TxUndoOverride, type TxUndoOverride,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -62,7 +62,7 @@ export class TransactionManager {
* Single authoritative lifecycle registry for transactions. * Single authoritative lifecycle registry for transactions.
* *
* We retain ended/canceled metadata after the tx object is released because * We retain ended/canceled metadata after the tx object is released because
* history semantics only persist `txId`; undo/redo still needs to resolve * history markers only persist `txId`; undo/redo still needs to resolve
* whether a tx was active or already ended when applying effective deltas. * whether a tx was active or already ended when applying effective deltas.
*/ */
private readonly transactionRecords = new Map<string, TransactionRecord>(); private readonly transactionRecords = new Map<string, TransactionRecord>();
@@ -161,7 +161,7 @@ export class TransactionManager {
return; return;
} }
mergeStoreDeltaSemantics(delta, { txUndoOverrides }); mergeStoreDeltaMarkers(delta, { txUndoOverrides });
} }
private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] { private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] {
@@ -196,7 +196,7 @@ export class TransactionManager {
delta: HistoryDelta, delta: HistoryDelta,
_context: HistoryEffectiveDeltaResolverContext, _context: HistoryEffectiveDeltaResolverContext,
): HistoryDelta { ): HistoryDelta {
const txUndoOverrides = delta.semantics?.txUndoOverrides; const txUndoOverrides = delta.markers?.txUndoOverrides;
if (!txUndoOverrides || txUndoOverrides.length === 0) { if (!txUndoOverrides || txUndoOverrides.length === 0) {
return delta; return delta;
} }
@@ -276,7 +276,7 @@ export class TransactionManager {
return HistoryDelta.create(effectiveElements, delta.appState, { return HistoryDelta.create(effectiveElements, delta.appState, {
id: delta.id, id: delta.id,
semantics: delta.semantics, markers: delta.markers,
}) as HistoryDelta; }) as HistoryDelta;
} }