How Patch-Based Undo/Redo History and Coalescing Work in the Instatic Editor
Instatic stores undo/redo state as Mutative patch pairs rather than full document clones, coalescing rapid edits into single entries using path-deduplication to keep memory usage constant regardless of burst length.
The Instatic visual editor implements a memory-efficient patch-based undo/redo history system using the Mutative library to capture state changes. Instead of deep-cloning the entire site document for every modification, the editor records discrete forward and inverse patches that describe exactly what changed and how to revert it. This approach enables granular undo/redo capabilities while maintaining O(change) complexity, even during rapid input bursts like continuous typing.
The HistoryEntry Structure
Each undoable operation in Instatic is encapsulated as a HistoryEntry defined in the store types. Rather than storing full state snapshots, the entry contains two patch arrays:
forward– Patches that replay the change (e.g., replacing a text value).inverse– Patches that revert the change (e.g., restoring the original text).coalesceKey– An optional string identifier (e.g.,props:<nodeId>:<prop>) that groups consecutive edits into a single logical step.
This structure lives in the history stacks (_historyPast and _historyFuture) as immutable records of every mutation applied to the site.
Running an Undoable Mutation
When you call mutateSite, mutateActiveTree, or similar helpers, the workflow defined in src/admin/pages/site/store/slices/site/helpers.ts executes a six-step process:
-
Snapshot the current store –
runHistoricMutationreads the existing state viaget(). -
Create a Mutative draft – The function invokes
create(cur, draft => recipe(draft), { enablePatches: true }), which generates both forward and inverse patch lists while running your mutation recipe against the draft. -
Strip the site prefix – Only patches touching the
sitesubtree are retained assiteForwardandsiteInverse, removing the leading'site'segment soapply(site, …)works directly on the document later. -
Apply live changes – Mutated top-level fields are copied back into the live store (
state[key] = produced[key]). -
Commit history –
commitHistory(state, { inverse, forward, coalesceKey })pushes the entry onto the past stack or folds it into the current top entry if coalescing applies (see below). -
Track dirty state – Dirty-tracking utilities flag which pages and visual components need persistence.
See the runHistoricMutation implementation (lines 52‑84) and commitHistory function (lines 15‑36) in helpers.ts.
Undo and Redo Actions
Undo and redo are thin wrappers around the stored patch pairs implemented in src/admin/pages/site/store/slices/site/undoRedoActions.ts (lines 24‑82).
Undo pops the most recent HistoryEntry from _historyPast, applies its inverse patches to the current site using apply(site, entry.inverse), rebuilds derived artifacts like packageJson and siteRuntime, updates the dirty set, and pushes the entry onto _historyFuture.
Redo mirrors this process using the forward patches from _historyFuture.
Both actions clear the ongoing coalesce key by setting state._historyCoalesceKey = null, ensuring that the next burst of edits starts a fresh history entry rather than folding into the previously undone state.
Input-Burst Coalescing
To prevent every keystroke in an inline editor from polluting the undo stack, Instatic coalesces consecutive edits that share the same logical target.
The Coalesce Key
UI layers such as inlineEditSlice.ts and visualComponentsSlice.ts generate a coalesceKey (e.g., props:${pageId}:title) and pass it through the mutation options. This key is stored on the HistoryEntry and compared against state._historyCoalesceKey during the commit phase.
Folding Patches
When commitHistory detects a matching coalesce key, it invokes foldIntoCoalescedEntry (lines 34‑57 in helpers.ts) instead of pushing a new entry. This function deduplicates patches by path using three rules:
- Oldest inverse wins – The earliest inverse patch for a path is preserved, ensuring undo restores the value before the entire burst began.
- Newest forward wins – The final forward patch for a path is kept, representing the cumulative effect of the burst.
- Remove operations overwrite – If a
removeop appears, it supersedes prior patches for that path because deleting a non-existent key is a no-op.
After folding, the future stack is cleared (_historyFuture = []), disabling redo for the coalesced entry since the original sequence has been replaced by a single consolidated change.
Performance Benefits of Patch-Based History
The Instatic editor’s patch-based approach delivers significant performance advantages over traditional snapshot-based history:
- O(change) complexity – Mutative only records patches for mutated paths; no full-site deep clone is performed regardless of document size.
- Memory efficiency – Each
HistoryEntryholds at most one patch per touched path after coalescing, so a 100-keystroke typing burst results in a single tiny entry rather than 100 document clones. - Deterministic replay – Applying stored patches with
applyreproduces exactly the same document state as the original mutation, ensuring forward and inverse operations are bit-identical to the previous concat behavior.
Code Examples
Here is how you trigger a coalesced mutation for an inline text edit:
import { mutateActiveTree } from 'src/admin/pages/site/store/slices/site/helpers';
mutateActiveTree(
(tree) => {
const pageNode = tree.nodes[pageId]!;
pageNode.props.title = 'New title';
return true; // indicate that a change occurred
},
{ coalesceKey: `props:${pageId}:title` } // same key for every keystroke
);
To undo the last coalesced edit:
import { undo } from 'src/admin/pages/site/store/slices/site/undoRedoActions';
undo(); // pulls entry from _historyPast, applies inverse patches
Creating a manual HistoryEntry (rarely needed):
commitHistory(state, {
inverse: [{ op: 'replace', path: ['pages', pageId, 'props', 'title'], value: 'Old' }],
forward: [{ op: 'replace', path: ['pages', pageId, 'props', 'title'], value: 'New' }],
coalesceKey: null
});
Summary
- Instatic uses Mutative patch pairs (forward/inverse) instead of cloning full documents for undo/redo.
- HistoryEntry objects are committed via
runHistoricMutationandcommitHistoryinsrc/admin/pages/site/store/slices/site/helpers.ts. - Coalescing collapses rapid edits sharing a
coalesceKeyinto single entries using path-deduplication logic infoldIntoCoalescedEntry. - Undo/redo actions in
undoRedoActions.tsapply stored patches withapply()and manage_historyPastand_historyFuturestacks. - This architecture provides O(change) complexity and constant memory usage for input bursts regardless of document size.
Frequently Asked Questions
How does Instatic handle memory usage during rapid typing?
Instatic uses input-burst coalescing to prevent memory leaks during continuous typing. Each keystroke generates patches, but if they share the same coalesceKey, foldIntoCoalescedEntry deduplicates them by keeping only the oldest inverse patch and newest forward patch per path. This ensures that a 100-character typing burst creates exactly one HistoryEntry instead of 100 document clones, keeping memory usage constant.
What happens to the redo stack when I perform a new edit after undoing?
According to the implementation in commitHistory, any new mutation clears the _historyFuture array (state._historyFuture = []) and sets state._canRedo = false. This mirrors standard undo/redo behavior found in most applications: once you undo and then make a new change, the redo path is destroyed because the document state has diverged from the future history branch.
Why does Instatic strip the 'site' segment from patches?
The library strips the leading 'site' segment from patch paths (stored as siteForward and siteInverse) so that apply(site, …) can work directly on the document subtree. This optimization eliminates redundant path traversal during undo/redo operations and ensures compatibility with the Mutative apply function, which expects paths relative to the target object being patched.
Can I disable coalescing for specific mutations?
Yes. Simply omit the coalesceKey property when calling mutation helpers like mutateActiveTree or mutateSite. When coalesceKey is null or undefined, commitHistory treats the edit as a distinct operation and pushes a new HistoryEntry onto the stack instead of folding it into the previous entry. This is useful for discrete actions like saving or component deletion where each step should be undoable individually.
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 →