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

  1. Property-level incremental — Only changed fields are recorded, not deep clones
  2. Core-encapsulated — All Immer machinery is hidden inside the reducer; plugins are transparent
  3. Viewport stability — Camera state (pan/zoom) is excluded from undo to prevent jarring viewport jumps
  4. 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


Last updated: 2026-06-14