When Undo Lies: Fixing a Shared History Stack in a Multi-Table Editor
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:
- Edit table A ten times → 10 states consumed
- Edit table B five times → 5 more states consumed
- Switch back to table A and hit undo → you get a state from 15 operations ago, most of which touched table B, not A
- All 50 slots fill up and table C never gets more than a few meaningful steps
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:
- Snapshot scope:
table.outerHTMLinstead of$('#tableContainer').html()— smaller, faster, and targeted - Storage scope: per-slot Map entry instead of a shared array
- Restore scope:
replaceWithto 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:
_activateSheet()insheetManager.js— afterwindow.activeSheetIdandwindow.currentTableare both set to the new sheet's state- The table click handler in
tifany.js— whencurrentTablechanges to a different table element
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.