refactor(transaction): consolidate ledger into single file, fix store API, and optimize snapshots

- Move TransactionLedger + types from @excalidraw/element into
  packages/excalidraw/transaction.ts (no external consumers)
- Move commitSyntheticIncrement from App passthrough to Store directly,
  fix didAppStateChange to use Delta.isRightDifferent instead of
  presence-check, and replace isolated increments queue with direct
  scheduleMicroAction + flushMicroActions
- Remove commitSyntheticHistoryEntry from App, expose store and
  createTransaction on AppClassProperties instead
- Replace O(N) deep-copy snapshot in updateScene with shallow map copy
  (element refs are safe — ledger ignores index, the only in-place
  mutated prop, and deep-copies only recorded elements)
- Merge ledger + manager tests into single transaction.test.tsx
- Update dev-docs to reflect new file structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Di
2026-04-17 21:59:11 +10:00
parent 2177e1cfa0
commit bfd4af2367
14 changed files with 1342 additions and 1527 deletions
+43 -74
View File
@@ -5,117 +5,86 @@
This system lets many canvas mutations over a time window (for example AI streaming patch/action/create) render immediately, while still producing a single undoable durable history entry.
- AI streaming typically emits many `NEVER` updates (for real-time visual feedback).
- `NEVER` updates store snapshots, but does not write undo/redo history.
- `NEVER` updates advance Store snapshots, but do not write undo/redo history.
- If we only do a final "one IMMEDIATELY at the end", we often miss the intended delta because snapshots may already be aligned.
- Transaction + Ledger creates synthetic logical before/after snapshots, then commits one durable increment with correct undo semantics.
## Key Files
### `packages/excalidraw/transaction/manager.ts`
### `packages/excalidraw/transaction.ts`
- `TransactionManager`: session entry point.
- `TransactionSession`:
- `apply()`: runs a mutation and records before/after diff automatically.
- `capture()`: records caller-provided before/after snapshots.
- `setAppStatePatch()`: optional appState before/after patch for synthetic history.
- `commit()` / `cancel()`: finalize session and return an idempotent summary.
### `packages/element/src/transaction/ledger.ts`
- `TransactionLedger` keeps net mutation state across steps.
- `recordStep()`: folds multiple updates into per-element baseline/target/touchedProps.
- `buildSyntheticSnapshots()`: reconciles ledger state with live scene under merge policy and returns `logicalBefore` / `logicalAfter` for history commit.
### `packages/excalidraw/components/App.tsx`
- `commitSyntheticHistoryEntry()` converts `logicalBefore/logicalAfter` into `StoreSnapshot -> StoreChange -> StoreDelta`.
- It does not mutate the live scene; it only commits a synthetic durable increment.
- `appStateBefore/After` are overlaid on top of current observed appState baseline, so only provided keys participate in synthetic diff.
- `TransactionLedger`: keeps net mutation state across steps.
- `recordStep()`: folds multiple updates into per-element baseline/target/touchedProps.
- `buildSyntheticSnapshots()`: reconciles ledger state with live scene under merge policy and returns `elementsBefore` / `elementsAfter` for history commit.
- `TransactionManager`: thin factory holding the app reference. `create(options?)` returns a `Transaction`.
- `Transaction` (class):
- `tx.updateScene(data)`: wraps `app.updateScene` with `captureUpdate: NEVER`. Snapshots elements before/after and records the diff into the ledger. Accumulates appState intent.
- `tx.commit()`: builds synthetic before/after snapshots from the ledger, calls `store.commitSyntheticIncrement()`, returns a summary.
- `tx.cancel()`: finalizes without committing history.
- Merge-policy types (`ConflictWinner`, `ConflictScope`, `TransactionMergePolicy`) and defaults.
### `packages/element/src/store.ts`
- `enqueueIsolatedIncrement()` / `flushIsolatedIncrements()` / `requestIsolatedIncrementFlush()`.
- Adds an isolated lane/queue for synthetic durable increments outside regular micro/macro capture flow.
- Flush runs only when store is idle (`isIdle()`); otherwise it defers and retries next frame.
### `packages/excalidraw/types.ts` + `packages/element/src/transaction/types.ts` + `packages/excalidraw/transaction/types.ts`
- App/type wiring and merge-policy types used by the transaction system.
- `store.commitSyntheticIncrement()`: builds `StoreSnapshot -> StoreChange -> StoreDelta` from logical before/after element maps and optional appState patches. Schedules a micro IMMEDIATELY so the delta is flushed on the next `componentDidUpdate -> store.commit()`.
## How They Work Together
1. Caller opens a session (`app.transactionManager.open()`).
2. AI steps keep rendering in real time (commonly via `NEVER`).
3. Session records diffs continuously via `apply()` / `capture()` into ledger.
4. On session `commit()`:
- ledger builds `logicalBefore/logicalAfter`
- `App.commitSyntheticHistoryEntry()` computes change/delta
- `store.enqueueIsolatedIncrement()` pushes to isolated queue
5. Store flushes isolated queue when idle and emits durable increment to undo stack.
1. Caller creates a transaction (`excalidrawAPI.createTransaction()` or `app.transactionManager.create()`).
2. `tx.updateScene()` applies mutations with `NEVER` for real-time visual feedback.
3. Ledger records element diffs on each `updateScene()` call; appState intent is auto-accumulated.
4. On `tx.commit()`:
- Ledger builds `logicalBefore/logicalAfter` reconciled against live scene
- `store.commitSyntheticIncrement()` computes change/delta and schedules micro IMMEDIATELY
5. Next `componentDidUpdate -> store.commit()` flushes the micro and emits a durable increment to the undo stack.
Result:
- streaming visuals stay immediate;
- history remains one entry per transaction;
- synthetic durable commit stays isolated from ongoing regular capture batches.
- synthetic durable commit is scheduled via micro IMMEDIATELY, flushed naturally by the next commit cycle.
## Why Isolated Lane/Queue Matters
## AppState Strategy
Without isolated lane, synthetic increments may interleave with normal editor flows (pointer interactions, macro captures, pending micro actions), causing:
The ledger tracks elements per-id per-property. AppState cannot use the same snapshot approach because `setState` is batched.
- polluted undo boundaries,
- unstable commit timing,
- non-deterministic behavior due to shared capture channel contention.
Core value of isolated lane:
- structural isolation from regular capture flow,
- timing control (flush only on idle),
- deferred retry when not idle.
Instead, the transaction auto-accumulates caller intent: each `tx.updateScene({ appState })` call merges the passed `appState` patch into `accumulatedAppState`. The transaction saves `initialAppState` at creation time. At commit: `appStateBefore = initialAppState`, `appStateAfter = initialAppState + accumulatedAppState`.
## Sample Usage
### AI Short Session (compact multi-step)
### Short Session
```ts
const { summary } = await app.transactionManager.run(async (tx) => {
// run() auto-commits on success and auto-cancels on thrown errors.
// Short session can still include multiple steps.
await tx.apply(async () => executeUpdateStepA());
await tx.apply(async () => executeUpdateStepB());
});
const tx = app.transactionManager.create();
tx.updateScene({ elements: updatedElementsA });
tx.updateScene({ elements: updatedElementsB });
tx.commit();
```
### AI Long Session (multi-step mixed flow)
### Long Session (multi-step with error handling)
```ts
const session = app.transactionManager.open();
const tx = app.transactionManager.create();
try {
// step 1
await session.apply(async () => executeUpdateStep1());
// step 2
await session.apply(async () => executeCreateStep2());
// step 3
await session.apply(async () => executeUpdateStep3());
// Optional: include selected appState keys in synthetic history
session.setAppStatePatch({
before: { selectedElementIds: prevSelectedIds },
after: { selectedElementIds: nextSelectedIds },
tx.updateScene({ elements: step1Elements });
tx.updateScene({ elements: step2Elements });
tx.updateScene({
elements: step3Elements,
appState: { selectedElementIds: nextSelectedIds },
});
session.commit();
tx.commit();
} catch (error) {
session.cancel();
tx.cancel();
throw error;
}
```
## Notes
- `commit()` and `cancel()` are idempotent.
- merge policy currently uses `conflictWinner` + `conflictScope`.
- current implementation prioritizes semantic correctness and readability; fine-grained performance optimizations can be added later.
- `commit()` and `cancel()` are idempotent (repeated calls return the same summary).
- `updateScene()` throws after `commit()` or `cancel()`.
- Merge policy uses `conflictWinner` + `conflictScope` to resolve live/transaction divergence.
- Multi-transaction concurrency works naturally — each tx has its own ledger.