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 statepatches– forward patches describing what changedinverse– 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:
- Pops the last entry from
_historyPast - Applies the inverse patches via
apply(site, entry.inverse) - Pushes the entry onto
_historyFuturefor potential redo - Updates derived state (
canUndo,canRedo,hasUnsavedChanges)
Performing Redo
The redo() function (lines 58-60) reverses the process:
- Pops from
_historyFuture - Applies forward patches via
apply(site, entry.forward) - 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
enablePatchesoption 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_HISTORYfromdefaults.ts, evicting oldest entries automatically. - Temporal coalescing – The
coalesceKeymechanism merges consecutive edits sharing the same key, typically used for typing bursts. - Deterministic replay – Undo applies
entry.inversevia Mutative'sapply(), while redo appliesentry.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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →