How TAFNE's Undo/Redo Works: A Case Study in HTML Snapshot Stacks
TLDR
Building an undo/redo system for web table editors using full HTML innerHTML snapshots provides predictable state restoration ($('#tableContainer').html(state)). Guarding restoration passes with an isRestoring boolean flag prevents initialization hooks from triggering recursive state capture loops.
| History State Approach | Restoration Method | Mutation Handling | Loop Prevention |
|---|---|---|---|
| Granular Diff Operations | Patch application | Complex (Risk of drift) | Manual event suppression |
| HTML Snapshot Stack | Single DOM html() Overwrite | 100% Deterministic Restoration | Wrapped isRestoring Guard |
State restoration triggers DOM mount listeners, corrupting the history stack with recursive snapshots
Implementing undo/redo via full DOM snapshots avoids complex differential patching algorithms. Storing container innerHTML strings allows the application to restore previous states using a single DOM write operation.
However, restoring HTML re-triggers feature initialization hooks. If these initialization routines call saveCurrentState() as a side effect, restoration triggers immediate state pushes, corrupting the history stack.
A transactional boolean guard suppresses state logging during rehydration to block infinite save loops
We wrapped history management inside a dedicated manager class using an isRestoring execution guard:
UNGUARDED CORRUPTION LOOP:
[Undo Click] ──> [Restore DOM HTML] ──> [DOM Mount Triggered] ──> [saveState()] ──┐
▲ │
└───────────────── (Infinite Recursive Push / Corruption) ────────────────┘
GUARDED TRANSACTION FLOW: [Undo Click] ──> Set isRestoring = true │ ▼ [Restore DOM HTML] ──> [DOM Mount Triggered] ──> [saveState()] │ (isRestoring is true? Yes) │ ▼ [Discard Save!] │ ▼ Set isRestoring = false (Success)
Here is how we implemented the history manager with the guard flag:
class TableHistoryManager {
constructor(maxHistory = 50) {
this.history = [];
this.currentIndex = -1;
this.maxHistory = maxHistory;
this.isRestoring = false;
}
saveState(tableHtml) { // 1. Suppress state saves during active restoration passes if (this.isRestoring) return; if (!tableHtml || !tableHtml.trim()) return;
// 2. Prevent duplicate sequential states if (this.currentIndex >= 0 && this.history[this.currentIndex] === tableHtml) return;
// 3. Truncate forward timeline on new mutations this.history = this.history.slice(0, this.currentIndex + 1); this.history.push(tableHtml);
if (this.history.length > this.maxHistory) { this.history.shift(); } else { this.currentIndex++; } }
restoreState(renderCallback) { if (this.currentIndex < 0) return; this.isRestoring = true; try { renderCallback(this.history[this.currentIndex]); } finally { this.isRestoring = false; // Reset guard after DOM re-initialization } } }
By setting isRestoring = true during state rehydration, initialization listeners run safely without pushing redundant snapshots onto the undo stack.
Rule of thumb: Wrap state restoration callbacks in a transactional lock to prevent DOM initialization hooks from recursively pushing state into the undo stack.