How Patch-Based Undo/Redo History Works with Mutative in Instatic

Instatic leverages Mutative to capture forward and inverse patches for every site mutation, storing them in a bounded, coalesced stack that enables deterministic undo/redo via apply() without cloning the entire document.

The Instatic visual editor maintains a complete editing history without the memory overhead of full state snapshots. By integrating the Mutative library for immutable updates, the application records only the granular patches needed to reverse or replay changes, achieving O(change) performance regardless of site size.

Capturing Mutations with Mutative

All undoable editor actions flow through the helper functions defined in [helpers.ts](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts). The core mechanism resides in runHistoricMutation, which creates a Mutative draft with patches enabled:

// From helpers.ts, lines 60-71
import { create, apply } from 'mutative';

function runHistoricMutation(recipe, options) {
  const [next, patches, inverse] = create(
    currentState,
    recipe,
    { enablePatches: true }
  );
  // patches = forward changes, inverse = backward changes
  return { next, patches, inverse };
}

When a mutation executes, Mutative returns three critical pieces of data:

  • next – the mutated draft state
  • patches – forward patches describing what changed
  • inverse – inverse patches describing how to revert those changes

Scoping and Filtering Patches

Not every state change belongs in the history. The system filters patches to include only those affecting the 'site' property of the store, ensuring UI state and selection metadata remain outside the undo stack. As implemented in [helpers.ts](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts) (lines 77-84), the leading 'site' path segment is stripped so that apply(site, …) operates directly on the site object rather than the full store wrapper.

Managing the History Stack

Committing History Entries

The commitHistory function (lines 15-36 of [helpers.ts](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts)) constructs a HistoryEntry object and pushes it onto the _historyPast array:

interface HistoryEntry {
  inverse: Patch[];
  forward: Patch[];
  coalesceKey?: string;
}

// MAX_HISTORY is imported from defaults.ts
if (_historyPast.length >= MAX_HISTORY) {
  _historyPast.shift(); // Evict oldest entry
}
_historyPast.push(entry);

Coalescing Rapid Edits

To prevent every keystroke from generating a separate undo step, Instatic implements temporal coalescing via coalesceKey. When consecutive mutations share the same key (typically a burst identifier for typing sessions), foldIntoCoalescedEntry (lines 34-58) merges the new patches into the most recent history entry rather than appending a new one. This deduplication ensures that an entire paragraph of typing collapses into a single undo action.

Executing Undo and Redo Operations

The undo and redo logic lives in [undoRedoActions.ts](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/undoRedoActions.ts). These actions manipulate the history stacks and apply patches to the site state.

Performing Undo

When undo() is called (lines 26-28), the system:

  1. Pops the last entry from _historyPast
  2. Applies the inverse patches via apply(site, entry.inverse)
  3. Pushes the entry onto _historyFuture for potential redo
  4. Updates derived state (canUndo, canRedo, hasUnsavedChanges)

Performing Redo

The redo() function (lines 58-60) reverses the process:

  1. Pops from _historyFuture
  2. Applies forward patches via apply(site, entry.forward)
  3. Returns the entry to _historyPast

Both operations also trigger pruneCanvasSelectionDraft to clear any in-progress selection state that might conflict with the restored history.

Type Definitions and Data Structures

The shape of the history entry is formally declared in [types.ts](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/types.ts) (lines 116-124):

export interface HistoryEntry {
  inverse: Patch[];
  forward: Patch[];
  coalesceKey?: string;
}

This type supports the patch-pair architecture while providing the optional metadata required for coalescing strategies.

Practical Usage Examples

Accessing Undo from Components

The useUndo hook is exported from the main store entry point at [store.ts](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/store.ts):

import { useUndo } from '@site/store';

function Toolbar() {
  const undo = useUndo();
  return (
    <button onClick={undo} disabled={!canUndo}>
      Undo
    </button>
  );
}

Recording a Simple Mutation

Use mutateSite to wrap changes that should appear in history:

import { useSiteHelpers } from '@site/store';

function addPage() {
  const { mutateSite } = useSiteHelpers();
  
  mutateSite((site) => {
    const page = createPage(site, 'New Page', '/new');
    site.pages.push(page);
    return true; // Return truthy to signal a change occurred
  });
}

Coalescing Typing Bursts

Pass a coalesceKey to group rapid edits into a single undo step:

function updateText(nodeId: string, text: string, burstId: string) {
  const { mutateActiveTree } = useSiteHelpers();
  
  mutateActiveTree(
    (tree) => {
      const node = tree.nodes[nodeId];
      if (node) node.props.text = text;
    },
    { coalesceKey: burstId } // All edits with this key merge into one entry
  );
}

Summary

  • Patch-based storage – Instatic uses Mutative's enablePatches option to capture forward and inverse patches instead of cloning entire state trees.
  • Scoped recording – Only mutations under the 'site' path are recorded; UI state mutations are applied live but excluded from history.
  • Bounded memory – The history stack respects MAX_HISTORY from defaults.ts, evicting oldest entries automatically.
  • Temporal coalescing – The coalesceKey mechanism merges consecutive edits sharing the same key, typically used for typing bursts.
  • Deterministic replay – Undo applies entry.inverse via Mutative's apply(), while redo applies entry.forward, guaranteeing idempotent state restoration.

Frequently Asked Questions

How does Instatic avoid storing full state snapshots for every change?

Instead of cloning the entire site document, Instatic records Mutative patches—arrays of operations describing exactly which paths changed and how. Each history entry stores only the forward and inverse patch arrays (typically bytes or kilobytes) rather than megabytes of duplicated JSON, keeping memory usage proportional to edit complexity rather than document size.

What happens when the undo history exceeds the maximum limit?

When _historyPast reaches MAX_HISTORY (defined in defaults.ts), the commitHistory function shifts the oldest entry off the stack before pushing the new one. This creates a sliding window of undoable actions; once an action falls off the bottom of the stack, it can no longer be undone, though the current state remains valid.

How does coalescing work for typing bursts?

The foldIntoCoalescedEntry helper (lines 34-58 of helpers.ts) checks if the incoming mutation's coalesceKey matches the key of the most recent history entry. If they match, the new forward and inverse patches are merged into the existing entry, with later patches overwriting earlier ones for the same path. This ensures that typing "Hello" generates one undo entry rather than five separate ones.

Why are only 'site' scoped patches included in the history?

The editor separates document state (the actual site content) from transient UI state (selection highlights, panel visibility, scroll positions). By filtering patches to those whose first path segment is 'site', Instatic ensures that undo operations affect only the content users expect to revert, while preserving their current tool context and selection.

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 →