Engineering Journal
Table Formatter
Table Formatter

How TAFNE's Undo/Redo Works: A Case Study in HTML Snapshot Stacks

2026-05-11

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 ApproachRestoration MethodMutation HandlingLoop Prevention
Granular Diff OperationsPatch applicationComplex (Risk of drift)Manual event suppression
HTML Snapshot StackSingle DOM html() Overwrite100% Deterministic RestorationWrapped 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.
Read this post in the full Engineering Journal →