Undo/Redo (TimeTravel)
OpenGPEX uses an incremental undo system powered by Immer JSON Patches — recording only the minimal diff between states, not full snapshots. This achieves near-zero memory overhead and sub-2ms undo operations.
Core Design Principles
- Property-level incremental — Only changed fields are recorded, not deep clones
- Core-encapsulated — All Immer machinery is hidden inside the reducer; plugins are transparent
- Viewport stability — Camera state (pan/zoom) is excluded from undo to prevent jarring viewport jumps
- Developer extensible — Plugins can register custom undo behavior via the command bus
How It Works
Every state mutation via Immer.produce() automatically generates a pair of patches:
// Forward patch (for redo)
[{ op: "replace", path: "/frames/byId/f1/layers/byId/l1/x", value: 120 }]
// Inverse patch (for undo)
[{ op: "replace", path: "/frames/byId/f1/layers/byId/l1/x", value: 100 }]
These tiny JSON objects (typically 50-200 bytes) are pushed onto the history stack.
Data Structure
interface HistoryStep {
label?: string; // Human-readable description
patches: Patch[]; // Forward patches (redo)
inversePatches: Patch[]; // Reverse patches (undo)
timestamp: number; // When this edit occurred
}
interface HistoryState {
past: HistoryStep[]; // Undo stack
future: HistoryStep[]; // Redo stack
maxSteps: number; // Memory budget (default: 100)
}
Undo/Redo Flow
[Undo] → Pop from past → Apply inversePatches → Push to future
[Redo] → Pop from future → Apply patches → Push to past
[New edit] → Clear future stack (branching)
Viewport Stability
Camera state is excluded from undo patches. When you undo a layer move:
- ✅ Layer position reverts
- ✅ Camera stays exactly where you are looking
- ❌ Camera does NOT jump back to where it was during the edit
This is achieved by filtering camera-related paths from patch generation.
Fast Track Integration
During drag operations (via Fast Track):
- No patches generated during the interaction (60fps updates)
- One patch generated on
tx.commit()capturing the full delta from start to end - Result: dragging a layer 500px produces exactly 1 undo step, not 500
Memory Management
| Setting | Default | Description |
|---|---|---|
maxSteps |
100 | Maximum history entries before oldest is discarded |
| Avg patch size | 50-200 bytes | Much smaller than full-state snapshots (MB) |
| Entity cleanup | Automatic | Removing a frame/layer purges dangling patches |
API
// Undo last action
actions.history.undo();
// Redo last undone action
actions.history.redo();
// Create a labeled checkpoint (groups next mutations)
actions.history.checkpoint('Rotate layer 90°');
Comparison with Full-Snapshot Undo
| Metric | Full Snapshot | OpenGPEX (Patches) |
|---|---|---|
| Memory per step | 1-10 MB | 50-200 bytes |
| Undo latency | 10-50ms (deep copy) | <2ms (patch apply) |
| React re-renders | Full tree (refs broken) | Targeted (structural sharing) |
| 100 undo steps | 100-1000 MB RAM | ~20 KB RAM |
Next Steps
- State Management — How Immer powers the store
- Fast Track — How interactions produce single undo steps
- Actions & Commands — Where patches are generated
Last updated: 2026-06-14