# How Patch-Based Undo/Redo History and Coalescing Work in the Instatic Editor

> Discover how Instatic's patch based undo redo history coalesces edits using path deduplication for constant memory usage. Learn efficient state management.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-29

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts)** executes a six-step process:

1. **Snapshot the current store** – `runHistoricMutation` reads the existing state via `get()`.

2. **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.

3. **Strip the site prefix** – Only patches touching the `site` subtree are retained as `siteForward` and `siteInverse`, removing the leading `'site'` segment so `apply(site, …)` works directly on the document later.

4. **Apply live changes** – Mutated top-level fields are copied back into the live store (`state[key] = produced[key]`).

5. **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).

6. **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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/inlineEditSlice.ts) and [`visualComponentsSlice.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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 `remove` op 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 `HistoryEntry` holds 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 `apply` reproduces 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:

```typescript
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:

```typescript
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):

```typescript
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 `runHistoricMutation` and `commitHistory` in [`src/admin/pages/site/store/slices/site/helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts).
- **Coalescing** collapses rapid edits sharing a `coalesceKey` into single entries using path-deduplication logic in `foldIntoCoalescedEntry`.
- **Undo/redo actions** in [`undoRedoActions.ts`](https://github.com/CoreBunch/Instatic/blob/main/undoRedoActions.ts) apply stored patches with `apply()` and manage `_historyPast` and `_historyFuture` stacks.
- 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.