How the Penpot Undo/Redo System Works: Deep Dive into the Undo-Stack Data Structure

Penpot’s undo/redo system uses an immutable vector-based stack with a cursor index that tracks history, truncates redo states on new actions, and limits total entries to prevent memory bloat.

The penpot/penpot repository implements a robust, immutable undo/redo mechanism built on a custom undo-stack data structure. This system separates concerns between a pure, functional stack module and a higher-level workspace event layer that manages transactions, groups, and DOM integration. Understanding these two layers reveals how Penpot maintains reliable state history without sacrificing performance.

Core Undo-Stack Data Structure

The foundation lives in common/src/app/common/data/undo_stack.cljc. This Clojure/ClojureScript module defines a persistent data structure composed of a vector of items and an integer cursor (:index).

Stack State and Constants

A fresh stack initializes with make-stack, returning {:index -1 :items []}. The index -1 represents an empty state with no current entry. The module enforces a hard limit via MAX-UNDO-SIZE to cap memory usage.

(require '[app.common.data.undo-stack :as us])

;; Create empty stack
(def stack (us/make-stack))
;; => {:index -1, :items []}

Append Logic and Truncation

The append function implements the core history management algorithm:

  1. Duplicate suppression: Skips adding if the new entry equals the current item
  2. Redo truncation: When index > 0, truncates the vector to (subvec 0 (inc index)), permanently dropping any forward history
  3. Size enforcement: If the stack exceeds MAX-UNDO-SIZE, removes the oldest element via (subvec 1 …) and adjusts the index

Relative source locations: MAX-UNDO-SIZE, append.

Cursor Movement and Inspection

The stack uses pointer arithmetic rather than structural modification for undo/redo:

  • peek: Returns the item at current index or nil if out of bounds
  • undo: Decrements index, clamped at 0
  • redo: Increments index toward the stack end
  • size: Returns (inc index), representing available undo steps
  • fixup: Replaces the item at current index without moving the cursor (used for post-processing entries like adding IDs after creation)

Workspace-Level Undo Management in Penpot

The workspace state (:workspace-undo) in frontend/src/app/main/data/workspace/undo.cljs wraps the raw stack with domain-specific logic for the design editor.

State Structure

The workspace maintains:

{:workspace-undo {:index -1
                  :items []
                  :transaction nil
                  :transactions-pending …}}

Adding History Entries

append-undo (dispatched via ::append-undo Potok events) routes changes through three paths based on context:

  • accumulate-undo-entry: Adds to an open transaction
  • stack-undo-entry: Merges with the current entry for "stackable" actions (e.g., repeated nudging)
  • add-undo-entry: Creates a fresh entry via app.common.data.undo-stack/append

Source references: append-undo, add-undo-entry.

Executing Undo and Redo

The undo and redo functions in the workspace layer do not directly manipulate application state. Instead, they:

  1. Calculate target index using undo-to-index
  2. Walk the stack to collect :undo-changes (or :redo-changes)
  3. Dispatch dch/commit-changes with the aggregated diffs

For grouped entries (sharing a :undo-group UUID), undo-to-index scans backwards to find the first group index and jumps the cursor to the entry preceding the entire group.

Source references: undo, redo, undo-to-index.

Transactions and Grouping

Transactions collapse multiple fine-grained changes into single undoable units:

  • start-undo-transaction opens a transaction with a unique ID
  • Changes accumulate until commit-undo-transaction closes it
  • Auto-commit occurs after discard-transaction-time-millis (default 20 seconds) via check-open-transactions

Groups allow multiple consecutive entries to be undone as one atomic unit. When undo detects a group boundary, it reverts all entries in that group simultaneously.

Source references: start-undo-transaction, commit-undo-transaction.

Materializing Specific History Points

materialize-undo allows the UI history panel to jump to a specific index without applying intermediate steps. It sets the workspace cursor directly; subsequent logic determines whether to apply undo or redo changesets to reach that state from the current position.

Source: materialize-undo.

Practical Code Examples

Low-Level Stack Operations

Direct usage of the immutable stack:

(require '[app.common.data.undo-stack :as us])

;; Initialize
(def st (us/make-stack))

;; Append three operations
(def st (-> st
            (us/append {:op :add-shape :id "rect1"})
            (us/append {:op :resize :id "rect1" :width 100})
            (us/append {:op :resize :id "rect1" :width 200})))

;; State now: {:index 2, :items [...]}

;; Undo once
(def st (us/undo st))
(us/peek st)
;; => {:op :resize :id "rect1" :width 100}

;; Redo
(def st (us/redo st))
(us/peek st)
;; => {:op :resize :id "rect1" :width 200}

Transaction-Based Undo in the UI

Batching rapid changes during a drag operation:

;; Start transaction
(ptk/dispatch! (undo/start-undo-transaction (js/Symbol.) :timeout 10000))

;; Accumulate changes during drag
(ptk/dispatch!
  (undo/append-undo
    {:undo-changes [{:op :move :id "shape1" :dx 5 :dy 0}]
     :redo-changes [{:op :move :id "shape1" :dx -5 :dy 0}]
     :undo-group nil
     :tags #{}}
    false))

;; Commit when drag ends
(ptk/dispatch! (undo/commit-undo-transaction transaction-id))

Handling Grouped Entries

When consecutive entries share a :undo-group UUID, a single undo command reverts the entire group:

;; Entries 5 and 6 share :undo-group #uuid "abc..."
;; This single call reverts both
(ptk/dispatch! (undo/undo))
;; Cursor jumps to index 4 (before the group started)

Summary

  • The undo-stack in common/src/app/common/data/undo_stack.cljc provides an immutable vector with a cursor index, enforcing MAX-UNDO-SIZE limits and automatically truncating redo history on new appends.
  • Workspace integration in frontend/src/app/main/data/workspace/undo.cljs wraps this structure with Potok events, supporting transactions, stacking, and group-aware navigation.
  • Cursor movement (undo/redo functions) shifts the pointer without deleting data, while append handles memory management by evicting oldest entries and dropping unreachable redo states.
  • Transactions (start-undo-transaction, commit-undo-transaction) collapse granular changes into single entries, while groups allow multi-entry atomic undo via undo-to-index scanning.
  • The system is fully tested in common/test/common_tests/undo_stack_test.cljc with edge cases for truncation, duplicates, and size limits.

Frequently Asked Questions

How does Penpot prevent infinite memory growth in the undo history?

The append function enforces a hard limit via MAX-UNDO-SIZE. When adding a new entry would exceed this limit, it removes the oldest item using (subvec 1 …) and adjusts the cursor accordingly. This ensures the undo-stack never grows beyond the configured maximum regardless of user session length.

What happens to redo history when I perform a new action after undoing?

The append operation automatically truncates redo history. If the current cursor index is greater than -1 (meaning you've undone one or more steps), append truncates the items vector to (subvec 0 (inc index)) before adding the new entry. This destroys the forward history, consistent with standard undo/redo behavior in creative applications.

How do transactions work in Penpot's undo system?

Transactions allow multiple changes to aggregate into a single undoable unit. When start-undo-transaction opens a transaction window, subsequent calls to append-undo accumulate into the existing entry rather than creating new ones. The transaction auto-commits after discard-transaction-time-millis (20 seconds) or manually via commit-undo-transaction. This prevents the history panel from filling with micro-changes during continuous operations like dragging or typing.

Can I undo multiple steps at once in Penpot?

Yes, through groups. When entries share a :undo-group UUID, the undo function detects the group boundary and calculates the index before the first grouped entry using undo-to-index. It then reverts all entries in that range as a single atomic operation. The UI history panel also supports materialize-undo to jump to any specific point in the stack.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →