bfd4af2367
- 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>
91 lines
4.1 KiB
Plaintext
91 lines
4.1 KiB
Plaintext
# Transactions
|
|
|
|
## Transaction + Ledger System
|
|
|
|
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 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.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 `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`
|
|
|
|
- `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 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 is scheduled via micro IMMEDIATELY, flushed naturally by the next commit cycle.
|
|
|
|
## AppState Strategy
|
|
|
|
The ledger tracks elements per-id per-property. AppState cannot use the same snapshot approach because `setState` is batched.
|
|
|
|
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
|
|
|
|
### Short Session
|
|
|
|
```ts
|
|
const tx = app.transactionManager.create();
|
|
|
|
tx.updateScene({ elements: updatedElementsA });
|
|
tx.updateScene({ elements: updatedElementsB });
|
|
|
|
tx.commit();
|
|
```
|
|
|
|
### Long Session (multi-step with error handling)
|
|
|
|
```ts
|
|
const tx = app.transactionManager.create();
|
|
|
|
try {
|
|
tx.updateScene({ elements: step1Elements });
|
|
tx.updateScene({ elements: step2Elements });
|
|
tx.updateScene({
|
|
elements: step3Elements,
|
|
appState: { selectedElementIds: nextSelectedIds },
|
|
});
|
|
|
|
tx.commit();
|
|
} catch (error) {
|
|
tx.cancel();
|
|
throw error;
|
|
}
|
|
```
|
|
|
|
## Notes
|
|
|
|
- `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.
|