Engineering Journal
Table Formatter
Table Formatter

When Undo Lies: Fixing a Shared History Stack in a Multi-Table Editor

2026-05-30

The first version of TAFNE's undo system worked. Mostly. But there was a class of failure that was invisible until someone actually used the tool the way a real person would.

Open three tables. Edit the first one ten times. Switch to the third one. Hit undo. Watch the third table revert to its initial state, not the state you just left it in. The undo counter says 7 — but those 7 states belong to table one.

That's the bug. Here's why it happened and how I fixed it.

The original design

The old TableHistoryManager was a single class instance, shared across the entire app:

class TableHistoryManager {
    constructor(maxHistory = 50) {
        this.history = [];
        this.currentIndex = -1;
        this.maxHistory = 50;
    }
}

window.historyManager = new TableHistoryManager();

Every saveCurrentState() call pushed the full #tableContainer innerHTML — every table on the page — into one flat array. One array, 50 slots, shared across all tables, all sheets, all modes.

In the best case (one table, one sheet) this was fine. In any realistic session it degraded:

The 50-state limit sounds generous. Split across 3 tables and 2 sheets, it's effectively 8 states per table.

The right mental model

Each (sheet, table) pair is an independent editing context. Edits to table A in sheet 1 should never affect the undo depth of table B in sheet 2. The history manager needs to be a map, not an array.

sheet-1 :: t-0 → [state0, state1, state2, ...]   ← 50 slots, independent
sheet-1 :: t-1 → [state0, state1, ...]             ← 50 slots, independent
sheet-2 :: t-0 → [state0, ...]                     ← 50 slots, independent

The key is ${sheetId}::${tableId}. Each slot gets its own 50-state stack.

The new implementation

I replaced the class entirely with a module-level Map and a set of functions:

const MAX_PER_SLOT = 50;
const _slots = new Map();
let _isRestoring = false;

function _currentKey() { const sheetId = window.activeSheetId ?? 'default'; const tableId = window.currentTable ? ($(window.currentTable).attr('data-tifany-id') ?? 'default') : 'default'; return ${sheetId}::${tableId}; }

function _getSlot(key) { if (!_slots.has(key)) { _slots.set(key, { history: [], currentIndex: -1 }); } return _slots.get(key); }

saveCurrentState now saves only the active table's outerHTML, not the whole container:

function saveCurrentState() {
    if (_isRestoring || !window.currentTable) return;

const tableHtml = window.currentTable.outerHTML; if (!tableHtml?.trim()) return;

const key = _currentKey(); const slot = _getSlot(key);

if (slot.currentIndex >= 0 && slot.history[slot.currentIndex] === tableHtml) return;

slot.history = slot.history.slice(0, slot.currentIndex + 1); slot.history.push(tableHtml);

if (slot.history.length > MAX_PER_SLOT) { slot.history.shift(); } else { slot.currentIndex++; }

_updateButtons(key); }

Three things changed from the old version:

  1. Snapshot scope: table.outerHTML instead of $('#tableContainer').html() — smaller, faster, and targeted
  2. Storage scope: per-slot Map entry instead of a shared array
  3. Restore scope: replaceWith to swap only the affected table, not the full container

Restore in-place

The old undo blasted the entire #tableContainer:

// OLD
$('#tableContainer').html(state);

That was necessary when the snapshot held the full container. But it also wiped every other table's live DOM, destroying their edit context.

The new version finds the target table and swaps it in-place:

function _restoreSlot(key, slot) {
    _isRestoring = true;

const tableHtml = slot.history[slot.currentIndex]; const tableId = window.currentTable ? $(window.currentTable).attr('data-tifany-id') : null;

const $target = tableId ? $(#tableContainer table[data-tifany-id="${tableId}"]) : $(#tableContainer table).first();

if ($target.length) { $target.replaceWith(tableHtml); window.currentTable = tableId ? $(#tableContainer table[data-tifany-id="${tableId}"])[0] : $(#tableContainer table)[0]; }

if (typeof window.setupTableInteraction === 'function') { window.setupTableInteraction(); }

_isRestoring = false; _updateButtons(key); }

setupTableInteraction rebuilds the ruler, re-attaches drag handlers, and re-observes the table. Only the one table that was restored goes through that path. Everything else on the page is untouched.

Syncing the UI on context switch

One detail that required a dedicated function: the undo/redo button state needs to update when the user switches which table or sheet they're working in.

Before this fix, the counter showed the previous table's depth even after you clicked a different table. The fix: a syncHistoryButtons() function that reads _currentKey() at the moment it's called.

function syncHistoryButtons() {
    _updateButtons(_currentKey());
}
window.syncHistoryButtons = syncHistoryButtons;

This gets called in two places:

  1. _activateSheet() in sheetManager.js — after window.activeSheetId and window.currentTable are both set to the new sheet's state
  2. The table click handler in tifany.js — when currentTable changes to a different table element
The result: undo/redo always reflects the stack for wherever you currently are.

Cleaning up on sheet delete

The Map grows as you work. When a sheet is deleted, its slots should be freed. A clearSheetHistory(sheetId) function removes all entries whose key starts with ${sheetId}:::

function clearSheetHistory(sheetId) {
    for (const key of _slots.keys()) {
        if (key.startsWith(${sheetId}::)) _slots.delete(key);
    }
    _updateButtons(_currentKey());
}

Called in deleteSheet() in sheetManager.js before switching to the adjacent sheet.

Backward compatibility

One file still referenced the old window.historyManager object: nodeEditor.js, which is gated off (window.nodeEditorEnabled = false). It calls window.historyManager.saveState() and reads window.historyManager.isRestoring. Rather than delete the object, I kept a minimal shim:

window.historyManager = {
    get isRestoring() { return _isRestoring; },
    set isRestoring(v) { _isRestoring = v; },
    saveState() {},  // node editor is gated; no-op
    clear() { _slots.clear(); _updateButtons(_currentKey()); }
};

The gated code doesn't crash. When node editor gets integrated, it'll get its own slot keyed to some node-editor context identifier.

What changed in practice

Before: 50 states total across all tables and sheets. Edit table A 40 times, and table B only gets 10 undo steps before the oldest states start rolling off.

After: 50 states per table per sheet. An editor session with 4 tables across 3 sheets has up to 600 independent undo steps available. The counters on the buttons reflect exactly the depth of the current table's history, not the global pool.

The code for this is in tableHistory.js if you want to look at the full thing. The key insight is small: when multiple editing contexts share the same history stack, the limit that feels generous becomes the failure mode.

Read this post in the full Engineering Journal →